# -*- coding: utf-8 eval: (blacken-mode 1) -*-
# SPDX-License-Identifier: GPL-2.0-or-later
#
# October 1 2021, Christian Hopps <chopps@labn.net>
#
# Copyright (c) 2021-2022, LabN Consulting, L.L.C.
#
# pylint: disable=protected-access
"""A module that defines objects for standalone use."""
import asyncio
import base64
import errno
import getpass
import glob
import ipaddress
import logging
import os
import random
import re
import shlex
import socket
import subprocess
import time

from pathlib import Path


try:
    # We only want to require yaml for the gen cloud image feature
    import yaml
except ImportError:
    pass

from . import cli
from .base import BaseMunet
from .base import Bridge
from .base import Commander
from .base import InterfaceMixin
from .base import LinuxNamespace
from .base import MunetError
from .base import SharedNamespace
from .base import Timeout
from .base import _async_get_exec_path
from .base import _get_exec_path
from .base import cmd_error
from .base import commander
from .base import fsafe_name
from .base import get_exec_path_host
from .config import config_subst
from .config import config_to_dict_with_key
from .config import find_matching_net_config
from .config import find_with_kv
from .config import merge_kind_config
from .watchlog import WatchLog


class L3ContainerNotRunningError(MunetError):
    """Exception if no running container exists."""


def get_loopback_ips(c, nid):
    if ip := c.get("ip"):
        if ip == "auto":
            return [ipaddress.ip_interface("10.255.0.0/32") + nid]
        if isinstance(ip, str):
            return [ipaddress.ip_interface(ip)]
        return [ipaddress.ip_interface(x) for x in ip]
    return []


def make_ip_network(net, inc):
    n = ipaddress.ip_network(net)
    return ipaddress.ip_network(
        (n.network_address + inc * n.num_addresses, n.prefixlen)
    )


def make_ip_interface(ia, inc):
    ia = ipaddress.ip_interface(ia)
    # this turns into a /32 fix this
    ia = ia + ia.network.num_addresses * inc
    # IPv6
    ia = ipaddress.ip_interface(str(ia).replace("/32", "/24").replace("/128", "/64"))
    return ia


def get_ip_network(c, brid, ipv6=False):
    ip = c.get("ipv6" if ipv6 else "ip")
    if ip and str(ip) != "auto":
        try:
            ifip = ipaddress.ip_interface(ip)
            if ifip.ip == ifip.network.network_address:
                return ifip.network
            return ifip
        except ValueError:
            return ipaddress.ip_network(ip)
    if ipv6:
        return make_ip_interface("fc00::fe/64", brid)
    return make_ip_interface("10.0.0.254/24", brid)


def parse_pciaddr(devaddr):
    comp = re.match(
        "(?:([0-9A-Fa-f]{4}):)?([0-9A-Fa-f]{2}):([0-9A-Fa-f]{2}).([0-7])", devaddr
    ).groups()
    if comp[0] is None:
        comp[0] = "0000"
    return [int(x, 16) for x in comp]


def read_int_value(path):
    return int(open(path, encoding="ascii").read())


def read_str_value(path):
    return open(path, encoding="ascii").read().strip()


def read_sym_basename(path):
    return os.path.basename(os.readlink(path))


async def to_thread(func):
    """to_thread for python < 3.9."""
    try:
        return await asyncio.to_thread(func)
    except AttributeError:
        logging.warning("Using backport to_thread")
        return await asyncio.get_running_loop().run_in_executor(None, func)


def convert_ranges_to_bitmask(ranges):
    bitmask = 0
    for r in ranges.split(","):
        if "-" not in r:
            bitmask |= 1 << int(r)
        else:
            x, y = (int(x) for x in r.split("-"))
            for b in range(x, y + 1):
                bitmask |= 1 << b
    return bitmask


class ExternalNetwork(SharedNamespace, InterfaceMixin):
    """A network external to munet."""

    def __init__(self, name=None, unet=None, logger=None, mtu=None, config=None):
        """Create an external network."""
        del logger  # avoid linter
        del mtu  # avoid linter
        # Do we want to use os.getpid() rather than unet.pid?
        super().__init__(name, pid=unet.pid, nsflags=unet.nsflags, unet=unet)
        self.config = config if config else {}

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)
        await super()._async_delete()


class L2Bridge(Bridge):
    """A linux bridge with no IP network address."""

    def __init__(self, name=None, unet=None, logger=None, mtu=None, config=None):
        """Create a linux Bridge."""
        super().__init__(name=name, unet=unet, logger=logger, mtu=mtu)

        self.config = config if config else {}

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)
        await super()._async_delete()


class L3Bridge(Bridge):
    """A linux bridge with associated IP network address."""

    def __init__(self, name=None, unet=None, logger=None, mtu=None, config=None):
        """Create a linux Bridge."""
        super().__init__(name=name, unet=unet, logger=logger, mtu=mtu)

        self.config = config if config else {}

        self.ip_interface = get_ip_network(self.config, self.id)
        if hasattr(self.ip_interface, "network"):
            self.ip_address = self.ip_interface.ip
            self.ip_network = self.ip_interface.network
            self.cmd_raises(f"ip addr add {self.ip_interface} dev {name}")
        else:
            self.ip_address = None
            self.ip_network = self.ip_interface

        self.logger.debug("%s: set IPv4 network address to %s", self, self.ip_interface)
        self.cmd_raises("sysctl -w net.ipv4.ip_forward=1")

        self.ip6_interface = None
        if self.unet.ipv6_enable:
            self.ip6_interface = get_ip_network(self.config, self.id, ipv6=True)
            if hasattr(self.ip6_interface, "network"):
                self.ip6_address = self.ip6_interface.ip
                self.ip6_network = self.ip6_interface.network
                self.cmd_raises(f"ip addr add {self.ip6_interface} dev {name}")
            else:
                self.ip6_address = None
                self.ip6_network = self.ip6_interface

            self.logger.debug(
                "%s: set IPv6 network address to %s", self, self.ip_interface
            )
            self.cmd_raises("sysctl -w net.ipv6.conf.all.forwarding=1")

        self.is_nat = self.config.get("nat", False)
        if self.is_nat:
            self.cmd_raises(
                "iptables -t nat -A POSTROUTING "
                f"-s {self.ip_network} ! -d {self.ip_network} "
                f"! -o {self.name} -j MASQUERADE"
            )

    def get_intf_addr(self, ifname, ipv6=False):
        # None is a valid interface, we have the same address for all interfaces
        # just make sure they aren't asking for something we don't have.
        if ifname is not None and ifname not in self.intfs:
            return None
        return self.ip6_interface if ipv6 else self.ip_interface

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)

        if self.config.get("nat", False):
            self.cmd_status(
                "iptables -t nat -D POSTROUTING "
                f"-s {self.ip_network} ! -d {self.ip_network} "
                f"! -o {self.name} -j MASQUERADE"
            )
        await super()._async_delete()


class NodeMixin:
    """Node attributes and functionality."""

    next_ord = 1

    @classmethod
    def _get_next_ord(cls):
        # Do not use `cls` here b/c that makes the variable class specific
        n = L3NodeMixin.next_ord
        L3NodeMixin.next_ord = n + 1
        return n

    def __init__(self, *args, config=None, **kwargs):
        """Create a Node."""
        super().__init__(*args, **kwargs)

        self.config = config if config else {}
        config = self.config

        self.id = int(config["id"]) if "id" in config else self._get_next_ord()

        self.cmd_p = None
        self.container_id = None
        self.cleanup_called = False

        # Clear and create rundir early
        assert self.unet is not None
        self.rundir = self.unet.rundir.joinpath(self.name)
        commander.cmd_raises(f"rm -rf {self.rundir}")
        commander.cmd_raises(f"mkdir -p {self.rundir}")

    def _shebang_prep(self, config_key):
        cmd = self.config.get(config_key, "").strip()
        if not cmd:
            return []

        script_name = fsafe_name(config_key)

        # shell_cmd is a union and can be boolean or string
        shell_cmd = self.config.get("shell", "/bin/bash")
        if not isinstance(shell_cmd, str):
            if shell_cmd:
                # i.e., "shell: true"
                shell_cmd = "/bin/bash"
            else:
                # i.e., "shell: false"
                shell_cmd = ""

        # If we have a shell_cmd then we create a cleanup_cmds file in run_cmd
        # and volume mounted it
        if shell_cmd:
            # Create cleanup cmd file
            cmd = cmd.replace("%CONFIGDIR%", str(self.unet.config_dirname))
            cmd = cmd.replace("%RUNDIR%", str(self.rundir))
            cmd = cmd.replace("%NAME%", str(self.name))
            cmd += "\n"

            # Write out our cleanup cmd file at this time too.
            cmdpath = os.path.join(self.rundir, f"{script_name}.shebang")
            with open(cmdpath, mode="w+", encoding="utf-8") as cmdfile:
                cmdfile.write(f"#!{shell_cmd}\n")
                cmdfile.write(cmd)
                cmdfile.flush()
            commander.cmd_raises(f"chmod 755 {cmdpath}")

            if self.container_id:
                # XXX this counts on it being mounted in container, ugly
                cmds = [f"/tmp/{script_name}.shebang"]
            else:
                cmds = [cmdpath]
        else:
            cmds = []
            if isinstance(cmd, str):
                cmds.extend(shlex.split(cmd))
            else:
                cmds.extend(cmd)
            cmds = [
                x.replace("%CONFIGDIR%", str(self.unet.config_dirname)) for x in cmds
            ]
            cmds = [x.replace("%RUNDIR%", str(self.rundir)) for x in cmds]
            cmds = [x.replace("%NAME%", str(self.name)) for x in cmds]

        return cmds

    async def _async_shebang_cmd(self, config_key, warn=True):
        cmds = self._shebang_prep(config_key)
        if not cmds:
            return 0

        rc, o, e = await self.async_cmd_status(cmds, warn=warn)
        if not rc and warn and (o or e):
            self.logger.info(
                f"async_shebang_cmd ({config_key}): %s", cmd_error(rc, o, e)
            )
        elif rc and warn:
            self.logger.warning(
                f"async_shebang_cmd ({config_key}): %s", cmd_error(rc, o, e)
            )
        else:
            self.logger.debug(
                f"async_shebang_cmd ({config_key}): %s", cmd_error(rc, o, e)
            )

        return rc

    def has_run_cmd(self) -> bool:
        return bool(self.config.get("cmd", "").strip())

    async def get_proc_child_pid(self, p):
        # commander is right for both unshare inline (our proc pidns)
        # and non-inline (root pidns).

        # This doesn't work b/c we can't get back to the root pidns

        rootcmd = self.unet.rootcmd
        pgrep = rootcmd.get_exec_path("pgrep")
        spid = str(p.pid)
        for _ in Timeout(4):
            if p.returncode is not None:
                self.logger.debug("%s: proc %s exited before getting child", self, p)
                return None

            rc, o, e = await rootcmd.async_cmd_status(
                [pgrep, "-o", "-P", spid], warn=False
            )
            if rc == 0:
                return int(o.strip())

            await asyncio.sleep(0.1)
            self.logger.debug(
                "%s: no child of proc %s: %s", self, p, cmd_error(rc, o, e)
            )
        self.logger.warning("%s: timeout getting child pid of proc %s", self, p)
        return None

    async def run_cmd(self):
        """Run the configured commands for this node."""
        self.logger.debug(
            "[rundir %s exists %s]", self.rundir, os.path.exists(self.rundir)
        )

        cmds = self._shebang_prep("cmd")
        if not cmds:
            return

        stdout = open(os.path.join(self.rundir, "cmd.out"), "wb")
        stderr = open(os.path.join(self.rundir, "cmd.err"), "wb")
        self.cmd_pid = None
        self.cmd_p = await self.async_popen(
            cmds,
            stdin=subprocess.DEVNULL,
            stdout=stdout,
            stderr=stderr,
            start_new_session=True,  # allows us to signal all children to exit
        )

        # If our process is actually the child of an nsenter fetch its pid.
        if self.nsenter_fork:
            self.cmd_pid = await self.get_proc_child_pid(self.cmd_p)

        self.logger.debug(
            "%s: async_popen %s => %s (cmd_pid %s)",
            self,
            cmds,
            self.cmd_p.pid,
            self.cmd_pid,
        )

        self.pytest_hook_run_cmd(stdout, stderr)

        return self.cmd_p

    async def _async_cleanup_cmd(self):
        """Run the configured cleanup commands for this node.

        This function is called by subclass' async_cleanup_cmd
        """
        self.cleanup_called = True

        return await self._async_shebang_cmd("cleanup-cmd")

    def has_cleanup_cmd(self) -> bool:
        return bool(self.config.get("cleanup-cmd", "").strip())

    async def async_cleanup_cmd(self):
        """Run the configured cleanup commands for this node."""
        if self.cleanup_called:
            return
        self.cleanup_called = True

        return await self._async_cleanup_cmd()

    def has_ready_cmd(self) -> bool:
        return bool(self.config.get("ready-cmd", "").strip())

    async def async_ready_cmd(self):
        """Run the configured ready commands for this node."""
        return not await self._async_shebang_cmd("ready-cmd", warn=False)

    def cmd_completed(self, future):
        self.logger.debug("%s: cmd completed callback", self)
        try:
            status = future.result()
            self.logger.debug(
                "%s: node cmd_p completed result: %s cmd: %s", self, status, self.cmd_p
            )
            self.cmd_pid = None
            self.cmd_p = None
        except asyncio.CancelledError:
            # Should we stop the container if we have one?
            self.logger.debug("%s: node cmd_p.wait() canceled", future)

    def pytest_hook_run_cmd(self, stdout, stderr):
        """Handle pytest options related to running the node cmd.

        This function does things such as launch tail'ing windows
        on the given files if requested by the user.

        Args:
            stdout: file-like object with a ``name`` attribute, or a path to a file.
            stderr: file-like object with a ``name`` attribute, or a path to a file.
        """
        if not self.unet:
            return

        outopt = self.unet.cfgopt.getoption("--stdout")
        outopt = outopt if outopt is not None else ""
        if outopt == "all" or self.name in outopt.split(","):
            outname = stdout.name if hasattr(stdout, "name") else stdout
            self.run_in_window(f"tail -n+1 -F {outname}", title=f"O:{self.name}")

        if stderr:
            erropt = self.unet.cfgopt.getoption("--stderr")
            erropt = erropt if erropt is not None else ""
            if erropt == "all" or self.name in erropt.split(","):
                errname = stderr.name if hasattr(stderr, "name") else stderr
                self.run_in_window(f"tail -n+1 -F {errname}", title=f"E:{self.name}")

    def pytest_hook_open_shell(self):
        if not self.unet:
            return

        gdbcmd = self.config.get("gdb-cmd")
        shellopt = self.unet.cfgopt.getoption("--gdb", "")
        should_gdb = gdbcmd and (shellopt == "all" or self.name in shellopt.split(","))
        use_emacs = self.unet.cfgopt.getoption("--gdb-use-emacs", False)

        if should_gdb and not use_emacs:
            cmds = self.config.get("gdb-target-cmds", [])
            for cmd in cmds:
                gdbcmd += f" '-ex={cmd}'"

            bps = self.unet.cfgopt.getoption("--gdb-breakpoints", "").split(",")
            for bp in bps:
                if bp:
                    gdbcmd += f" '-ex=b {bp}'"

            cmds = self.config.get("gdb-run-cmds", [])
            for cmd in cmds:
                gdbcmd += f" '-ex={cmd}'"

            self.run_in_window(gdbcmd, ns_only=True)

            # We need somehow signal from the launched gdb that it has continued
            # this is non-trivial so for now just wait a while. :/
            time.sleep(5)
        elif should_gdb and use_emacs:
            gdbcmd = gdbcmd.replace("gdb ", "gdb -i=mi ")
            ecbin = self.get_exec_path("emacsclient")
            # output = self.cmd_raises(
            #     [ecbin, "--eval", f"(gdb \"{gdbcmd} -ex='p 123456'\")"]
            # )
            _ = self.cmd_raises([ecbin, "--eval", f'(gdb "{gdbcmd}")'])

            # can't figure out how to wait until symbols are loaded, until we do we just
            # have to wait "long enough" for the symbol load to finish :/
            # for _ in range(100):
            #     output = self.cmd_raises(
            #         [
            #             ecbin,
            #             "--eval",
            #             f"gdb-first-prompt",
            #         ]
            #     )
            #     if output == "nil\n":
            #         break
            #     time.sleep(0.25)

            time.sleep(10)

            cmds = self.config.get("gdb-target-cmds", [])
            for cmd in cmds:
                # we may want to quote quotes in the cmd string
                self.cmd_raises(
                    [
                        ecbin,
                        "--eval",
                        f'(gud-gdb-run-command-fetch-lines "{cmd}" "*gud-gdb*")',
                    ]
                )

            bps = self.unet.cfgopt.getoption("--gdb-breakpoints", "").split(",")
            for bp in bps:
                cmd = f"br {bp}"
                self.cmd_raises(
                    [
                        ecbin,
                        "--eval",
                        f'(gud-gdb-run-command-fetch-lines "{cmd}" "*gud-gdb*")',
                    ]
                )

            cmds = self.config.get("gdb-run-cmds", [])
            for cmd in cmds:
                # we may want to quote quotes in the cmd string
                self.cmd_raises(
                    [
                        ecbin,
                        "--eval",
                        f'(gud-gdb-run-command-fetch-lines "{cmd}" "*gud-gdb*")',
                    ]
                )
                gdbcmd += f" '-ex={cmd}'"

        shellopt = self.unet.cfgopt.getoption("--shell")
        shellopt = shellopt if shellopt else ""
        if shellopt == "all" or self.name in shellopt.split(","):
            self.run_in_window("bash")

    async def _async_delete(self):
        self.logger.debug("%s: NodeMixin sub-class _async_delete", self)

        if self.cmd_p:
            await self.async_cleanup_proc(self.cmd_p, self.cmd_pid)
            self.cmd_p = None

        # Next call users "cleanup_cmd:"
        try:
            if not self.cleanup_called:
                await self.async_cleanup_cmd()
        except Exception as error:
            self.logger.warning(
                "Got an error during delete from async_cleanup_cmd: %s", error
            )

        # delete the LinuxNamespace/InterfaceMixin
        await super()._async_delete()


class HostnetNode(NodeMixin, LinuxNamespace):
    """A node for running commands in the host network namespace."""

    def __init__(self, name, pid=True, **kwargs):
        if "net" in kwargs:
            del kwargs["net"]
        super().__init__(name, pid=pid, net=False, **kwargs)

        self.logger.debug("%s: creating", self)

        self.mgmt_ip = None
        self.mgmt_ip6 = None
        self.set_ns_cwd(self.rundir)

        super().pytest_hook_open_shell()
        self.logger.info("%s: created", self)

    def get_ifname(self, netname):  # pylint: disable=useless-return
        del netname
        return None

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)
        await super()._async_delete()


class SSHRemote(NodeMixin, Commander):
    """SSHRemote a node representing an ssh connection to something."""

    def __init__(
        self,
        name,
        **kwargs,
    ):
        super().__init__(name, **kwargs)

        self.logger.debug("%s: creating", self)

        # Things done in LinuxNamepsace we need to replicate here.
        self.rundir = self.unet.rundir.joinpath(self.name)
        self.unet.cmd_raises(f"rm -rf {self.rundir}")
        self.unet.cmd_raises(f"mkdir -p {self.rundir}")

        self.mgmt_ip = None
        self.mgmt_ip6 = None

        self.server = self.config["server"]
        self.port = int(self.config.get("server-port", 22))
        self.sudo_user = os.environ.get("SUDO_USER")
        self.user = self.config.get("ssh-user")
        if not self.user:
            self.user = self.sudo_user
        if not self.user:
            self.user = getpass.getuser()
        self.password = self.config.get("ssh-password")
        self.idfile = self.config.get("ssh-identity-file")
        self.use_host_network = None

        # Setup our base `pre-cmd` values
        #
        # We maybe should add environment variable transfer here in particular
        # MUNET_NODENAME. The problem is the user has to explicitly approve
        # of SendEnv variables.
        self.__base_cmd = []
        if self.idfile and self.sudo_user:
            self.__base_cmd += [
                get_exec_path_host("sudo"),
                "-E",
                f"-u{self.sudo_user}",
            ]
        self.__base_cmd.append(get_exec_path_host("ssh"))
        if self.port != 22:
            self.__base_cmd.append(f"-p{self.port}")
        self.__base_cmd.append("-q")
        self.__base_cmd.append("-oStrictHostKeyChecking=no")
        self.__base_cmd.append("-oUserKnownHostsFile=/dev/null")
        if self.idfile:
            self.__base_cmd.append(f"-i{self.idfile}")
        # Would be nice but has to be accepted by server config so not very useful.
        # self.__base_cmd.append("-oSendVar='TEST'")
        self.__base_cmd_pty = list(self.__base_cmd)
        self.__base_cmd_pty.append("-t")
        server_str = f"{self.user}@{self.server}"
        self.__base_cmd.append(server_str)
        self.__base_cmd_pty.append(server_str)
        # self.set_pre_cmd(pre_cmd, pre_cmd_tty)

        self.logger.info("%s: created", self)

    def _get_pre_cmd(self, use_str, use_pty, ns_only=False, **kwargs):
        # None on first use, set after
        if self.use_host_network is None:
            # We have networks now so try and ping the server in the namespace
            if not self.unet:
                self.use_host_network = True
            else:
                rc, _, _ = self.unet.cmd_status(f"ping -w1 -c1 {self.server}")
                if rc:
                    self.use_host_network = True
                else:
                    self.use_host_network = False

            if self.use_host_network:
                self.logger.debug("Using host namespace for ssh connection")
            else:
                self.logger.debug("Using munet namespace for ssh connection")

        if self.use_host_network:
            pre_cmd = []
        else:
            pre_cmd = self.unet._get_pre_cmd(False, use_pty, ns_only=False, **kwargs)
        if ns_only:
            return pre_cmd

        # XXX grab the env from kwargs and add to podman exec
        # env = kwargs.get("env", {})
        if use_pty:
            pre_cmd = pre_cmd + self.__base_cmd_pty
        else:
            pre_cmd = pre_cmd + self.__base_cmd
        return shlex.join(pre_cmd) if use_str else list(pre_cmd)

    def _get_cmd_as_list(self, cmd):
        """Given a list or string return a list form for execution.

        If cmd is a string then [cmd] is returned, for most other
        node types ["bash", "-c", cmd] is returned but in our case
        ssh is the shell.

        Args:
            cmd: list or string representing the command to execute.
            str_shell: if True and `cmd` is a string then run the
              command using bash -c
        Returns:
            list of commands to execute.
        """
        return [cmd] if isinstance(cmd, str) else cmd


# Would maybe like to refactor this into L3 and Node
class L3NodeMixin(NodeMixin):
    """A linux namespace with IP attributes."""

    def __init__(self, *args, unet=None, **kwargs):
        """Create an L3Node."""
        # logging.warning(
        #     "L3NodeMixin: config %s unet %s kwargs %s", config, unet, kwargs
        # )
        super().__init__(*args, unet=unet, **kwargs)

        self.mgmt_ip = None  # set in parser.py
        self.mgmt_ip6 = None  # set in parser.py
        self.host_intfs = {}
        self.phy_intfs = {}
        self.phycount = 0
        self.phy_odrivers = {}
        self.tapmacs = {}
        self.watched_logs = {}

        self.intf_tc_count = 0

        # super().__init__(name=name, **kwargs)

        self.mount_volumes()

        # -----------------------
        # Setup node's networking
        # -----------------------
        if not unet.ipv6_enable:
            # Disable IPv6
            self.cmd_raises("sysctl -w net.ipv6.conf.all.autoconf=0")
            self.cmd_raises("sysctl -w net.ipv6.conf.all.disable_ipv6=1")
            self.cmd_raises("sysctl -w net.ipv6.conf.all.forwarding=0")
        else:
            self.cmd_raises("sysctl -w net.ipv6.conf.all.autoconf=1")
            self.cmd_raises("sysctl -w net.ipv6.conf.all.disable_ipv6=0")
            self.cmd_raises("sysctl -w net.ipv6.conf.all.forwarding=1")

        self.next_p2p_network = ipaddress.ip_network(f"10.254.{self.id}.0/31")
        self.next_p2p_network6 = ipaddress.ip_network(f"fcff:ffff:{self.id:02x}::/127")

        self.loopback_ip = None
        self.loopback_ips = get_loopback_ips(self.config, self.id)
        self.loopback_ip = self.loopback_ips[0] if self.loopback_ips else None
        if self.loopback_ip:
            self.cmd_raises_nsonly(f"ip addr add {self.loopback_ip} dev lo")
            self.cmd_raises_nsonly("ip link set lo up")
            for i, ip in enumerate(self.loopback_ips[1:]):
                self.cmd_raises_nsonly(f"ip addr add {ip} dev lo:{i}")

        # -------------------
        # Setup node's rundir
        # -------------------

        # Not host path based, but we assume same
        self.set_ns_cwd(self.rundir)

        # Save the namespace pid
        with open(os.path.join(self.rundir, "nspid"), "w", encoding="ascii") as f:
            f.write(f"{self.pid}\n")

        with open(os.path.join(self.rundir, "nspids"), "w", encoding="ascii") as f:
            f.write(f'{" ".join([str(x) for x in self.pids])}\n')

        # Create a hosts file to map our name
        hosts_file = os.path.join(self.rundir, "hosts.txt")
        with open(hosts_file, "w", encoding="ascii") as hf:
            hf.write(
                f"""127.0.0.1\tlocalhost {self.name}
::1\tip6-localhost ip6-loopback
fe00::0\tip6-localnet
ff00::0\tip6-mcastprefix
ff02::1\tip6-allnodes
ff02::2\tip6-allrouters
"""
            )
        if hasattr(self, "bind_mount"):
            self.bind_mount(hosts_file, "/etc/hosts")

    def add_watch_log(self, path, watchfor_re=None):
        """Add a WatchLog to this nodes watched logs.

        Args:
            path: If relative is relative to the nodes ``rundir``
            watchfor_re: Regular expression to watch the log for and raise an exception
                         if found.

        Return:
            The watching task if request or None otherwise.
        """
        path = Path(path)
        if not path.is_absolute():
            path = self.rundir.joinpath(path)

        wl = WatchLog(path)
        self.watched_logs[wl.path] = wl
        task = wl.raise_if_match_task(watchfor_re) if watchfor_re else None
        return task

    async def console(
        self,
        concmd,
        prompt=r"(^|\r?\n)[^#\$]*[#\$] ",
        is_bourne=True,
        user=None,
        password=None,
        expects=None,
        sends=None,
        use_pty=False,
        will_echo=False,
        logfile_prefix="console",
        trace=True,
        **kwargs,
    ):
        """Create a REPL (read-eval-print-loop) driving a console.

        Args:
            concmd: string or list to popen with, or an already open socket
            prompt: the REPL prompt to look for, the function returns when seen
            is_bourne: True if the console is a bourne shell
            user: user name to log in with
            password: password to log in with
            expects: a list of regex other than the prompt, the standard user, or
                password to look for. "ogin:" or "[Pp]assword:"r.
            sends: what to send when an element of `expects` matches. Can be the
                empty string to send nothing.
            use_pty: true for pty based expect, otherwise uses popen (pipes/files)
            will_echo: bash is buggy in that it echo's to non-tty unlike any other
                sh/ksh, set this value to true if running back
            logfile_prefix: prefix for 3 logfiles opened to track the console i/o
            trace: trace the send/expect sequence
            **kwargs: kwargs passed on the _spawn.
        """
        lfname = os.path.join(self.rundir, f"{logfile_prefix}-log.txt")
        logfile = open(lfname, "a+", encoding="utf-8")
        logfile.write("-- start logging for: '{}' --\n".format(concmd))

        lfname = os.path.join(self.rundir, f"{logfile_prefix}-read-log.txt")
        logfile_read = open(lfname, "a+", encoding="utf-8")
        logfile_read.write("-- start read logging for: '{}' --\n".format(concmd))

        lfname = os.path.join(self.rundir, f"{logfile_prefix}-send-log.txt")
        logfile_send = open(lfname, "a+", encoding="utf-8")
        logfile_send.write("-- start send logging for: '{}' --\n".format(concmd))

        expects = [] if expects is None else expects
        sends = [] if sends is None else sends
        if user:
            expects.append("ogin:")
            sends.append(user + "\n")
        if password is not None:
            expects.append("assword:")
            sends.append(password + "\n")
        repl = await self.shell_spawn(
            concmd,
            prompt,
            expects=expects,
            sends=sends,
            use_pty=use_pty,
            will_echo=will_echo,
            is_bourne=is_bourne,
            logfile=logfile,
            logfile_read=logfile_read,
            logfile_send=logfile_send,
            trace=trace,
            **kwargs,
        )
        return repl

    async def monitor(
        self,
        sockpath,
        prompt=r"\(qemu\) ",
    ):
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.connect(sockpath)

        pfx = os.path.basename(sockpath)

        lfname = os.path.join(self.rundir, f"{pfx}-log.txt")
        logfile = open(lfname, "a+", encoding="utf-8")
        logfile.write("-- start logging for: '{}' --\n".format(sock))

        lfname = os.path.join(self.rundir, f"{pfx}-read-log.txt")
        logfile_read = open(lfname, "a+", encoding="utf-8")
        logfile_read.write("-- start read logging for: '{}' --\n".format(sock))

        p = self.spawn(sock, prompt, logfile=logfile, logfile_read=logfile_read)
        from .base import ShellWrapper  # pylint: disable=C0415

        p.send("\n")
        return ShellWrapper(p, prompt, None, will_echo=True, escape_ansi=True)

    def mount_volumes(self):
        for m in self.config.get("volumes", []):
            if isinstance(m, str):
                s = m.split(":", 1)
                if len(s) == 1:
                    self.tmpfs_mount(s[0])
                else:
                    spath = s[0]
                    if spath[0] == ".":
                        spath = os.path.abspath(
                            os.path.join(self.unet.config_dirname, spath)
                        )
                    self.bind_mount(spath, s[1])
                continue
            raise NotImplementedError("complex mounts for non-containers")

    def get_ifname(self, netname):
        for c in self.config["connections"]:
            if c["to"] == netname:
                return c["name"]
        return None

    def set_lan_addr(self, switch, cconf):
        if ip := cconf.get("ip"):
            ipaddr = ipaddress.ip_interface(ip)
            assert ipaddr.version == 4
        elif self.unet.autonumber and "ip" not in cconf:
            self.logger.debug(
                "%s: prefixlen of switch %s is %s",
                self,
                switch.name,
                switch.ip_network.prefixlen,
            )
            n = switch.ip_network
            ipaddr = ipaddress.ip_interface((n.network_address + self.id, n.prefixlen))
        else:
            ipaddr = None

        if ip := cconf.get("ipv6"):
            ip6addr = ipaddress.ip_interface(ip)
            assert ipaddr.version == 6
        elif self.unet.ipv6_enable and self.unet.autonumber and "ipv6" not in cconf:
            self.logger.debug(
                "%s: prefixlen of switch %s is %s",
                self,
                switch.name,
                switch.ip6_network.prefixlen,
            )
            n = switch.ip6_network
            ip6addr = ipaddress.ip_interface((n.network_address + self.id, n.prefixlen))
        else:
            ip6addr = None

        dns_network = self.unet.topoconf.get("dns-network")
        for ip in (ipaddr, ip6addr):
            if not ip:
                continue
            ipcmd = "ip " if ip.version == 4 else "ip -6 "
            if dns_network and dns_network == switch.name:
                if ip.version == 4:
                    self.mgmt_ip = ip.ip
                else:
                    self.mgmt_ip6 = ip.ip
            ifname = cconf["name"]
            self.set_intf_addr(ifname, ip)
            self.logger.debug("%s: adding %s to lan intf %s", self, ip, ifname)
            if not self.is_vm:
                self.intf_ip_cmd(ifname, ipcmd + f"addr add {ip} dev {ifname}")
                if hasattr(switch, "is_nat") and switch.is_nat:
                    swaddr = (
                        switch.ip_address if ip.version == 4 else switch.ip6_address
                    )
                    self.cmd_raises(ipcmd + f"route add default via {swaddr}")

    def _set_p2p_addr(self, other, cconf, occonf, ipv6=False):
        ipkey = "ipv6" if ipv6 else "ip"
        ipaddr = ipaddress.ip_interface(cconf[ipkey]) if cconf.get(ipkey) else None
        oipaddr = ipaddress.ip_interface(occonf[ipkey]) if occonf.get(ipkey) else None
        self.logger.debug(
            "%s: set_p2p_addr %s %s %s", self, other.name, ipaddr, oipaddr
        )

        if not ipaddr and not oipaddr:
            if self.unet.autonumber:
                if ipv6:
                    n = self.next_p2p_network6
                    self.next_p2p_network6 = make_ip_network(n, 1)
                else:
                    n = self.next_p2p_network
                    self.next_p2p_network = make_ip_network(n, 1)

                ipaddr = ipaddress.ip_interface(n)
                oipaddr = ipaddress.ip_interface((ipaddr.ip + 1, n.prefixlen))
            else:
                return

        if ipaddr:
            ifname = cconf["name"]
            self.set_intf_addr(ifname, ipaddr)
            self.logger.debug("%s: adding %s to p2p intf %s", self, ipaddr, ifname)
            if "physical" not in cconf and not self.is_vm:
                self.intf_ip_cmd(ifname, f"ip addr add {ipaddr} dev {ifname}")

        if oipaddr:
            oifname = occonf["name"]
            other.set_intf_addr(oifname, oipaddr)
            self.logger.debug(
                "%s: adding %s to other p2p intf %s", other, oipaddr, oifname
            )
            if "physical" not in occonf and not other.is_vm:
                other.intf_ip_cmd(oifname, f"ip addr add {oipaddr} dev {oifname}")

    def set_p2p_addr(self, other, cconf, occonf):
        self._set_p2p_addr(other, cconf, occonf, ipv6=False)
        if self.unet.ipv6_enable:
            self._set_p2p_addr(other, cconf, occonf, ipv6=True)

    async def add_host_intf(self, hname, lname, mtu=None):
        if hname in self.host_intfs:
            return
        self.host_intfs[hname] = lname

        # See if this interace is missing and needs to be fixed
        rc, o, _ = self.unet.rootcmd.cmd_status("ip -o link show")
        m = re.search(rf"\d+:\s+(\S+):.*altname {re.escape(hname)}\W", o)
        if m:
            # need to rename
            dname = m.group(1)
            self.logger.info("Fixing misnamed %s to %s", dname, hname)
            self.unet.rootcmd.cmd_status(
                f"ip link property del dev {dname} altname {hname}"
            )
            self.unet.rootcmd.cmd_status(f"ip link set {dname} name {hname}")

        # Make sure the interface is there.
        self.unet.rootcmd.cmd_raises(f"ip -o link show {hname}")
        self.unet.rootcmd.cmd_nostatus(f"ip link set {hname} down ")
        self.unet.rootcmd.cmd_raises(f"ip link set {hname} netns {self.pid}")

        # Wait for interface to show up in namespace
        for retry in range(0, 10):
            rc, o, _ = self.cmd_status(f"ip -o link show {hname}")
            if not rc:
                break
            if retry > 0:
                await asyncio.sleep(1)
        self.cmd_raises(f"ip link set {hname} name {lname}")
        if mtu:
            self.cmd_raises(f"ip link set {lname} mtu {mtu}")
        self.cmd_raises(f"ip link set {lname} up")

    async def rem_host_intf(self, hname):
        lname = self.host_intfs[hname]
        self.cmd_raises(f"ip link set {lname} down")
        self.cmd_raises(f"ip link set {lname} name {hname}")
        # We need to NOT run this command in the new pid namespace so that pid 1 is the
        # root init process and so the interface gets returned to the root namespace
        self.unet.rootcmd.cmd_raises(
            f"nsenter -t {self.pid} -n ip link set netns 1 dev {hname}"
        )
        del self.host_intfs[hname]

    async def add_phy_intf(self, devaddr, lname):
        """Add a physical inteface (i.e. mv it to vfio-pci driver.

        This is primarily useful for Qemu, but also for things like TREX or DPDK
        """
        if devaddr in self.phy_intfs:
            return
        self.phy_intfs[devaddr] = lname
        index = len(self.phy_intfs)

        _, _, off, fun = parse_pciaddr(devaddr)
        doffset = off * 8 + fun

        is_virtual = self.unet.rootcmd.path_exists(
            f"/sys/bus/pci/devices/{devaddr}/physfn"
        )
        if is_virtual:
            pfname = self.unet.rootcmd.cmd_raises(
                f"ls -1 /sys/bus/pci/devices/{devaddr}/physfn/net"
            ).strip()
            pdevaddr = read_sym_basename(f"/sys/bus/pci/devices/{devaddr}/physfn")
            _, _, poff, pfun = parse_pciaddr(pdevaddr)
            poffset = poff * 8 + pfun

            offset = read_int_value(
                f"/sys/bus/pci/devices/{devaddr}/physfn/sriov_offset"
            )
            stride = read_int_value(
                f"/sys/bus/pci/devices/{devaddr}/physfn/sriov_stride"
            )
            vf = (doffset - offset - poffset) // stride
            mac = f"02:cc:cc:cc:{index:02x}:{self.id:02x}"
            # Some devices require the parent to be up (e.g., ixbge)
            self.unet.rootcmd.cmd_raises(f"ip link set {pfname} up")
            self.unet.rootcmd.cmd_raises(f"ip link set {pfname} vf {vf} mac {mac}")
            self.unet.rootcmd.cmd_status(f"ip link set {pfname} vf {vf} trust on")
            self.tapmacs[devaddr] = mac

        self.logger.info("Adding physical PCI device %s as %s", devaddr, lname)

        # Get interface name and set to down if present
        ec, ifname, _ = self.unet.rootcmd.cmd_status(
            f"ls /sys/bus/pci/devices/{devaddr}/net/", warn=False
        )
        ifname = ifname.strip()
        if not ec and ifname:
            # XXX Should only do this is the device is up, and then likewise return it
            # up on exit self.phy_intfs_hostname[devaddr] = ifname
            self.logger.info(
                "Setting physical PCI device %s named %s down", devaddr, ifname
            )
            self.unet.rootcmd.cmd_status(
                f"ip link set {ifname} down 2> /dev/null || true"
            )

        # Get the current bound driver, and unbind
        try:
            driver = read_sym_basename(f"/sys/bus/pci/devices/{devaddr}/driver")
            driver = driver.strip()
        except Exception:
            driver = ""
        if driver:
            if driver == "vfio-pci":
                self.logger.info(
                    "Physical PCI device %s already bound to vfio-pci", devaddr
                )
                return

            self.logger.info(
                "Unbinding physical PCI device %s from driver %s", devaddr, driver
            )
            self.phy_odrivers[devaddr] = driver
            self.unet.rootcmd.cmd_raises(
                f"echo {devaddr} | timeout 10 tee /sys/bus/pci/drivers/{driver}/unbind"
            )

        # Add the device vendor and device id to vfio-pci in case it's the first time
        vendor = read_str_value(f"/sys/bus/pci/devices/{devaddr}/vendor")
        devid = read_str_value(f"/sys/bus/pci/devices/{devaddr}/device")
        self.logger.info("Adding device IDs %s:%s to vfio-pci", vendor, devid)
        ec, _, _ = self.unet.rootcmd.cmd_status(
            f"echo {vendor} {devid} > /sys/bus/pci/drivers/vfio-pci/new_id", warn=False
        )

        for retry in range(0, 10):
            if self.unet.rootcmd.path_exists(
                f"/sys/bus/pci/drivers/vfio-pci/{devaddr}"
            ):
                break
            if retry > 0:
                await asyncio.sleep(1)

            # Bind to vfio-pci if wasn't added with new_id
            self.logger.info("Binding physical PCI device %s to vfio-pci", devaddr)
            ec, _, _ = self.unet.rootcmd.cmd_status(
                f"echo {devaddr} > /sys/bus/pci/drivers/vfio-pci/bind"
            )

    async def rem_phy_intf(self, devaddr):
        """Remove a physical inteface (i.e. mv it away from vfio-pci driver.

        This is primarily useful for Qemu, but also for things like TREX or DPDK
        """
        lname = self.phy_intfs.get(devaddr, "")
        if lname:
            del self.phy_intfs[devaddr]

        # ifname = self.phy_intfs_hostname.get(devaddr, "")
        # if ifname
        #     del self.phy_intfs_hostname[devaddr]

        driver = self.phy_odrivers.get(devaddr, "")
        if not driver:
            self.logger.info(
                "Physical PCI device %s was bound to vfio-pci on entry", devaddr
            )
            return

        self.logger.info(
            "Unbinding physical PCI device %s from driver vfio-pci", devaddr
        )
        self.unet.rootcmd.cmd_status(
            f"echo {devaddr} | timeout 10 tee /sys/bus/pci/drivers/vfio-pci/unbind"
        )

        self.logger.info("Binding physical PCI device %s to driver %s", devaddr, driver)
        ec, _, _ = self.unet.rootcmd.cmd_status(
            f"echo {devaddr} > /sys/bus/pci/drivers/{driver}/bind"
        )
        if not ec:
            del self.phy_odrivers[devaddr]

    async def _async_delete(self):
        self.logger.debug("%s: L3NodeMixin sub-class _async_delete", self)

        # XXX do we need to run the cleanup command before these infra changes?

        # remove any hostintf interfaces
        for hname in list(self.host_intfs):
            await self.rem_host_intf(hname)

        # delete the LinuxNamespace/InterfaceMixin
        await super()._async_delete()

        # remove any hostintf interfaces, needs to come after normal exits
        for devaddr in list(self.phy_intfs):
            await self.rem_phy_intf(devaddr)


class L3NamespaceNode(L3NodeMixin, LinuxNamespace):
    """A namespace L3 node."""

    def __init__(self, name, pid=True, **kwargs):
        # logging.warning(
        #     "L3NamespaceNode: name %s MRO: %s kwargs %s",
        #     name,
        #     L3NamespaceNode.mro(),
        #     kwargs,
        # )
        super().__init__(name, pid=pid, **kwargs)
        super().pytest_hook_open_shell()

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)
        await super()._async_delete()


class L3ContainerNode(L3NodeMixin, LinuxNamespace):
    """An container (podman) based L3 node."""

    def __init__(self, name, config, **kwargs):
        """Create a Container Node."""
        self.cont_exec_paths = {}
        self.container_id = None
        self.container_image = config["image"]
        self.extra_mounts = []
        assert self.container_image

        self.cmd_p = None
        self.cmd_pid = None
        self.__base_cmd = []
        self.__base_cmd_pty = []

        # don't we have a mutini or cat process?
        super().__init__(
            name=name,
            config=config,
            # pid=True,
            # cgroup=True,
            # private_mounts=["/sys/fs/cgroup:/sys/fs/cgroup"],
            **kwargs,
        )

    @property
    def is_container(self):
        return True

    def get_exec_path(self, binary):
        """Return the full path to the binary executable inside the image.

        `binary` :: binary name or list of binary names
        """
        return _get_exec_path(binary, self.cmd_status, self.cont_exec_paths)

    async def async_get_exec_path(self, binary):
        """Return the full path to the binary executable inside the image.

        `binary` :: binary name or list of binary names
        """
        path = await _async_get_exec_path(
            binary, self.async_cmd_status, self.cont_exec_paths
        )
        return path

    def get_exec_path_host(self, binary):
        """Return the full path to the binary executable on the host.

        `binary` :: binary name or list of binary names
        """
        return get_exec_path_host(binary)

    def _get_pre_cmd(self, use_str, use_pty, ns_only=False, root_level=False, **kwargs):
        if ns_only:
            return super()._get_pre_cmd(
                use_str, use_pty, ns_only=True, root_level=root_level, **kwargs
            )
        if not self.cmd_p:
            if self.container_id:
                s = f"{self}: Running command in namespace b/c container exited"
                self.logger.warning("%s", s)
                raise L3ContainerNotRunningError(s)
            self.logger.debug("%s: Running command in namespace b/c no container", self)
            return super()._get_pre_cmd(
                use_str, use_pty, ns_only=True, root_level=root_level, **kwargs
            )

        # We need to enter our namespaces when running the podman command
        pre_cmd = super()._get_pre_cmd(
            False, use_pty, ns_only=True, root_level=root_level, **kwargs
        )

        # XXX grab the env from kwargs and add to podman exec
        # env = kwargs.get("env", {})
        if use_pty:
            pre_cmd = pre_cmd + self.__base_cmd_pty
        else:
            pre_cmd = pre_cmd + self.__base_cmd
        return shlex.join(pre_cmd) if use_str else pre_cmd

    def tmpfs_mount(self, inner):
        # eventually would be nice to support live mounting
        assert not self.container_id
        self.logger.debug("Mounting tmpfs on %s", inner)
        self.extra_mounts.append(f"--mount=type=tmpfs,destination={inner}")

    def bind_mount(self, outer, inner):
        # eventually would be nice to support live mounting
        assert not self.container_id
        # First bind the mount in the parent this allows things like /etc/hosts to work
        # correctly when running "nsonly" commands
        super().bind_mount(outer, inner)
        # Then arrange for binding in the container as well.
        self.logger.debug("Bind mounting %s on %s", outer, inner)
        if not self.test_nsonly("-e", outer):
            self.cmd_raises_nsonly(f"mkdir -p {outer}")
        self.extra_mounts.append(f"--mount=type=bind,src={outer},dst={inner}")

    def mount_volumes(self):
        args = []
        for m in self.config.get("volumes", []):
            if isinstance(m, str):
                s = m.split(":", 1)
                if len(s) == 1:
                    args.append("--mount=type=tmpfs,destination=" + m)
                else:
                    spath = s[0]
                    spath = os.path.abspath(
                        os.path.join(
                            os.path.dirname(self.unet.config["config_pathname"]), spath
                        )
                    )
                    if not self.test_nsonly("-e", spath):
                        self.cmd_raises_nsonly(f"mkdir -p {spath}")
                    args.append(f"--mount=type=bind,src={spath},dst={s[1]}")
                continue

        for m in self.config.get("mounts", []):
            margs = ["type=" + m["type"]]
            for k, v in m.items():
                if k == "type":
                    continue
                if v:
                    if k in ("src", "source"):
                        v = os.path.abspath(
                            os.path.join(
                                os.path.dirname(self.unet.config["config_pathname"]), v
                            )
                        )
                        if not self.test_nsonly("-e", v):
                            self.cmd_raises_nsonly(f"mkdir -p {v}")
                    margs.append(f"{k}={v}")
                else:
                    margs.append(f"{k}")
            args.append("--mount=" + ",".join(margs))

        if args:
            # Need to work on a way to mount into live container too
            self.extra_mounts += args

    def has_run_cmd(self) -> bool:
        return True

    async def run_cmd(self):
        """Run the configured commands for this node."""
        self.logger.debug("%s: starting container", self.name)
        self.logger.debug(
            "[rundir %s exists %s]", self.rundir, os.path.exists(self.rundir)
        )

        self.container_id = f"{self.name}-{os.getpid()}"
        proc_path = self.unet.proc_path if self.unet else "/proc"
        cmds = [
            get_exec_path_host("podman"),
            "run",
            f"--name={self.container_id}",
            # f"--net=ns:/proc/{self.pid}/ns/net",
            f"--net=ns:{proc_path}/{self.pid}/ns/net",
            f"--hostname={self.name}",
            f"--add-host={self.name}:127.0.0.1",
            # We can't use --rm here b/c podman fails on "stop".
            # u"--rm",
        ]

        if self.config.get("init", True):
            cmds.append("--init")

        if self.config.get("privileged", False):
            cmds.append("--privileged")
            # If we don't do this then the host file system is remounted read-only on
            # exit!
            cmds.append("--systemd=false")
        else:
            cmds.extend(
                [
                    # "--cap-add=SYS_ADMIN",
                    "--cap-add=NET_ADMIN",
                    "--cap-add=NET_RAW",
                ]
            )

        # Add volumes:
        if self.extra_mounts:
            cmds += self.extra_mounts

        # Add environment variables:
        envdict = self.config.get("env", {})
        if envdict is None:
            envdict = {}
        for k, v in envdict.items():
            cmds.append(f"--env={k}={v}")

        # Update capabilities
        cmds += [f"--cap-add={x}" for x in self.config.get("cap-add", [])]
        cmds += [f"--cap-drop={x}" for x in self.config.get("cap-drop", [])]
        # cmds += [f"--expose={x.split(':')[0]}" for x in self.config.get("ports", [])]
        cmds += [f"--publish={x}" for x in self.config.get("ports", [])]

        # Add extra flags from user:
        if "podman" in self.config:
            for x in self.config["podman"].get("extra-args", []):
                cmds.append(x.strip())

        # shell_cmd is a union and can be boolean or string
        shell_cmd = self.config.get("shell", "/bin/bash")
        if not isinstance(shell_cmd, str):
            if shell_cmd:
                shell_cmd = "/bin/bash"
            else:
                shell_cmd = ""

        # Create shebang files, filled later on
        for key in ("cleanup-cmd", "ready-cmd"):
            shebang_cmd = self.config.get(key, "").strip()
            if shell_cmd and shebang_cmd:
                script_name = fsafe_name(key)
                # Will write the file contents out when the command is run
                shebang_cmdpath = os.path.join(self.rundir, f"{script_name}.shebang")
                await self.async_cmd_raises_nsonly(f"touch {shebang_cmdpath}")
                await self.async_cmd_raises_nsonly(f"chmod 755 {shebang_cmdpath}")
                cmds += [
                    # How can we override this?
                    # u'--entrypoint=""',
                    f"--volume={shebang_cmdpath}:/tmp/{script_name}.shebang",
                ]

        cmd = self.config.get("cmd", "").strip()

        # See if we have a custom update for this `kind`
        if kind := self.config.get("kind", None):
            if kind in kind_run_cmd_update:
                cmds, cmd = await kind_run_cmd_update[kind](self, shell_cmd, cmds, cmd)

        # Create running command file
        if shell_cmd and cmd:
            assert isinstance(cmd, str)
            # make cmd \n terminated for script
            cmd = cmd.rstrip()
            cmd = cmd.replace("%CONFIGDIR%", str(self.unet.config_dirname))
            cmd = cmd.replace("%RUNDIR%", str(self.rundir))
            cmd = cmd.replace("%NAME%", str(self.name))
            cmd += "\n"
            cmdpath = os.path.join(self.rundir, "cmd.shebang")
            with open(cmdpath, mode="w+", encoding="utf-8") as cmdfile:
                cmdfile.write(f"#!{shell_cmd}\n")
                cmdfile.write(cmd)
                cmdfile.flush()
            self.cmd_raises_nsonly(f"chmod 755 {cmdpath}")
            cmds += [
                # How can we override this?
                # u'--entrypoint=""',
                f"--volume={cmdpath}:/tmp/cmds.shebang",
                self.container_image,
                "/tmp/cmds.shebang",
            ]
        else:
            # `cmd` is a direct run (no shell) cmd
            cmds.append(self.container_image)
            if cmd:
                if isinstance(cmd, str):
                    cmds.extend(shlex.split(cmd))
                else:
                    cmds.extend(cmd)

            cmds = [
                x.replace("%CONFIGDIR%", str(self.unet.config_dirname)) for x in cmds
            ]
            cmds = [x.replace("%RUNDIR%", str(self.rundir)) for x in cmds]
            cmds = [x.replace("%NAME%", str(self.name)) for x in cmds]

        stdout = open(os.path.join(self.rundir, "cmd.out"), "wb")
        stderr = open(os.path.join(self.rundir, "cmd.err"), "wb")
        # Using nsonly avoids using `podman exec` to execute the cmds.
        self.cmd_p = await self.async_popen_nsonly(
            cmds,
            stdin=subprocess.DEVNULL,
            stdout=stdout,
            stderr=stderr,
            start_new_session=True,  # keeps main tty signals away from podman
        )

        # If our process is actually the child of an nsenter fetch its pid.
        if self.nsenter_fork:
            self.cmd_pid = await self.get_proc_child_pid(self.cmd_p)

        self.logger.debug(
            "%s: async_popen => %s (%s)", self, self.cmd_p.pid, self.cmd_pid
        )

        self.pytest_hook_run_cmd(stdout, stderr)

        # ---------------------------------------
        # Now let's wait until container shows up
        # ---------------------------------------
        timeout = Timeout(30)
        while self.cmd_p.returncode is None and not timeout.is_expired():
            o = await self.async_cmd_raises_nsonly(
                f"podman ps -q -f name={self.container_id}"
            )
            if o.strip():
                break
            elapsed = int(timeout.elapsed())
            if elapsed <= 3:
                await asyncio.sleep(0.1)
            else:
                self.logger.info("%s: run_cmd taking more than %ss", self, elapsed)
                await asyncio.sleep(1)
        if self.cmd_p.returncode is not None:
            # leave self.container_id set to cause exception on use
            self.logger.warning(
                "%s: run_cmd exited quickly (%ss) rc: %s",
                self,
                timeout.elapsed(),
                self.cmd_p.returncode,
            )
        elif timeout.is_expired():
            self.logger.critical(
                "%s: timeout (%ss) waiting for container to start",
                self.name,
                timeout.elapsed(),
            )
            assert not timeout.is_expired()

        #
        # Set our precmd for executing in the container
        #
        self.__base_cmd = [
            get_exec_path_host("podman"),
            "exec",
            f"-eMUNET_RUNDIR={self.unet.rundir}",
            f"-eMUNET_NODENAME={self.name}",
            "-i",
        ]
        self.__base_cmd_pty = list(self.__base_cmd)  # copy list to pty
        self.__base_cmd.append(self.container_id)  # end regular list
        self.__base_cmd_pty.append("-t")  # add pty flags
        self.__base_cmd_pty.append(self.container_id)  # end pty list
        # self.set_pre_cmd(self.__base_cmd, self.__base_cmd_pty)  # set both pre_cmd

        self.logger.info("%s: started container", self.name)

        self.pytest_hook_open_shell()

        return self.cmd_p

    async def async_cleanup_cmd(self):
        """Run the configured cleanup commands for this node."""
        if self.cleanup_called:
            return
        self.cleanup_called = True

        if "cleanup-cmd" not in self.config:
            return

        # The opposite of other types, the container needs cmd_p running
        if not self.cmd_p:
            self.logger.warning("async_cleanup_cmd: container no longer running")
            return

        return await self._async_cleanup_cmd()

    def cmd_completed(self, future):
        try:
            log = self.logger.debug if self.deleting else self.logger.warning
            n = future.result()
            if self.deleting:
                log("contianer `cmd:` result: %s", n)
            else:
                log(
                    "contianer `cmd:` exited early, "
                    "try adding `tail -f /dev/null` to `cmd:`, result: %s",
                    n,
                )
        except asyncio.CancelledError as error:
            # Should we stop the container if we have one? or since we are canceled
            # we know we will be deleting soon?
            self.logger.warning(
                "node container cmd wait() canceled: %s:%s", future, error
            )
        self.cmd_p = None

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)

        if contid := self.container_id:
            try:
                if not self.cleanup_called:
                    self.logger.debug("calling user cleanup cmd")
                    await self.async_cleanup_cmd()
            except Exception as error:
                self.logger.warning(
                    "Got an error during delete from async_cleanup_cmd: %s", error
                )

            # Clear the container_id field we want to act like a namespace now.
            self.container_id = None

            o = ""
            e = ""
            if self.cmd_p:
                self.logger.debug("podman stop on container: %s", contid)
                if (rc := self.cmd_p.returncode) is None:
                    rc, o, e = await self.async_cmd_status_nsonly(
                        [get_exec_path_host("podman"), "stop", "--time=2", contid]
                    )
                if rc and rc < 128:
                    self.logger.warning(
                        "%s: podman stop on cmd failed: %s",
                        self,
                        cmd_error(rc, o, e),
                    )
                else:
                    # It's gone
                    self.cmd_p = None

            # now remove the container
            self.logger.debug("podman rm on container: %s", contid)
            rc, o, e = await self.async_cmd_status_nsonly(
                [get_exec_path_host("podman"), "rm", contid]
            )
            if rc:
                self.logger.warning(
                    "%s: podman rm failed: %s", self, cmd_error(rc, o, e)
                )
            else:
                self.logger.debug(
                    "podman removed container %s: %s", contid, cmd_error(rc, o, e)
                )

        await super()._async_delete()


class L3QemuVM(L3NodeMixin, LinuxNamespace):
    """An VM (qemu) based L3 node."""

    def __init__(self, name, config, **kwargs):
        """Create a Container Node."""
        self.cont_exec_paths = {}
        self.launch_p = None
        self.launch_pid = None
        self.qemu_config = config["qemu"]
        self.extra_mounts = []
        assert self.qemu_config
        self.cmdrepl = None
        self.conrepl = None
        self.is_kvm = False
        self.monrepl = None
        self.tapfds = {}
        self.cpu_thread_map = {}

        self.tapnames = {}

        self.use_ssh = False
        self.__base_cmd = []
        self.__base_cmd_pty = []

        super().__init__(name=name, config=config, pid=False, **kwargs)

        self.sockdir = self.rundir.joinpath("s")
        self.cmd_raises(f"mkdir -p {self.sockdir}")

        self.qemu_config = config_subst(
            self.qemu_config,
            name=self.name,
            rundir=os.path.join(self.rundir, self.name),
            configdir=self.unet.config_dirname,
        )
        self.ssh_keyfile = self.config.get("ssh-identity-file")
        if not self.ssh_keyfile:
            self.ssh_keyfile = self.qemu_config.get("sshkey")

        self.ssh_user = self.config.get("ssh-user")
        if not self.ssh_user:
            self.ssh_user = self.qemu_config.get("sshuser", "root")

        self.disk_created = False

    @property
    def is_vm(self):
        return True

    def __setup_ssh(self):
        if not self.ssh_keyfile:
            self.logger.warning("%s: No sshkey config", self)
            return False
        if not self.mgmt_ip and not self.mgmt_ip6:
            self.logger.warning("%s: No mgmt IP to ssh to", self)
            return False
        mgmt_ip = self.mgmt_ip if self.mgmt_ip else self.mgmt_ip6

        #
        # Since we have a keyfile shouldn't need to sudo
        # self.user = os.environ.get("SUDO_USER", "")
        # if not self.user:
        #     self.user = getpass.getuser()
        # self.__base_cmd = [
        #     get_exec_path_host("sudo"),
        #     "-E",
        #     f"-u{self.user}",
        #     get_exec_path_host("ssh"),
        # ]
        #
        port = 22
        self.__base_cmd = [get_exec_path_host("ssh")]
        if port != 22:
            self.__base_cmd.append(f"-p{port}")
        self.__base_cmd.append("-i")
        self.__base_cmd.append(self.ssh_keyfile)
        self.__base_cmd.append("-q")
        self.__base_cmd.append("-oStrictHostKeyChecking=no")
        self.__base_cmd.append("-oUserKnownHostsFile=/dev/null")
        # Would be nice but has to be accepted by server config so not very useful.
        # self.__base_cmd.append("-oSendVar='TEST'")
        self.__base_cmd_pty = list(self.__base_cmd)
        self.__base_cmd_pty.append("-t")

        self.__base_cmd.append(f"{self.ssh_user}@{mgmt_ip}")
        self.__base_cmd.append("--")
        self.__base_cmd_pty.append(f"{self.ssh_user}@{mgmt_ip}")
        # self.__base_cmd_pty.append("--")
        return True

    def _get_cmd_as_list(self, cmd):
        """Given a list or string return a list form for execution.

        If cmd is a string then [cmd] is returned, for most other
        node types ["bash", "-c", cmd] is returned but in our case
        ssh is the shell.

        Args:
            cmd: list or string representing the command to execute.
            str_shell: if True and `cmd` is a string then run the
              command using bash -c
        Returns:
            list of commands to execute.
        """
        if self.use_ssh and self.launch_p:
            return [cmd] if isinstance(cmd, str) else cmd
        return super()._get_cmd_as_list(cmd)

    def _get_pre_cmd(self, use_str, use_pty, ns_only=False, root_level=False, **kwargs):
        if ns_only:
            return super()._get_pre_cmd(
                use_str, use_pty, ns_only=True, root_level=root_level, **kwargs
            )

        if not self.launch_p:
            self.logger.debug("%s: Running command in namespace b/c no VM", self)
            return super()._get_pre_cmd(
                use_str, use_pty, ns_only=True, root_level=root_level, **kwargs
            )

        if not self.use_ssh:
            self.logger.debug(
                "%s: Running command in namespace b/c no SSH configured", self
            )
            return super()._get_pre_cmd(
                use_str, use_pty, ns_only=True, root_level=root_level, **kwargs
            )

        pre_cmd = self.unet._get_pre_cmd(use_str, use_pty, ns_only=True)

        # This is going to run in the process namespaces.
        # We really want it to run in the munet namespace which will
        # be different unless unshare_inline was used.
        #
        # XXX grab the env from kwargs and add to podman exec
        # env = kwargs.get("env", {})
        if use_pty:
            pre_cmd = pre_cmd + self.__base_cmd_pty
        else:
            pre_cmd = pre_cmd + self.__base_cmd
        return shlex.join(pre_cmd) if use_str else pre_cmd

    async def moncmd(self):
        """Uses internal REPL to send cmmand to qemu monitor and get reply."""

    def tmpfs_mount(self, inner):
        # eventually would be nice to support live mounting
        self.logger.debug("Mounting tmpfs on %s", inner)
        self.extra_mounts.append(("", inner, ""))

    #
    # bind_mount is actually being used to mount into the namespace
    #
    # def bind_mount(self, outer, inner):
    #     # eventually would be nice to support live mounting
    #     assert not self.container_id
    #     if self.test_host("-f", outer):
    #         self.logger.warning("Can't bind mount files with L3QemuVM: %s", outer)
    #         return
    #     self.logger.debug("Bind mounting %s on %s", outer, inner)
    #     if not self.test_host("-e", outer):
    #         self.cmd_raises(f"mkdir -p {outer}")
    #     self.extra_mounts.append((outer, inner, ""))

    def mount_volumes(self):
        """Mount volumes from the config."""
        args = []
        for m in self.config.get("volumes", []):
            if not isinstance(m, str):
                continue
            s = m.split(":", 1)
            if len(s) == 1:
                args.append(("", s[0], ""))
            else:
                spath = s[0]
                spath = os.path.abspath(
                    os.path.join(
                        os.path.dirname(self.unet.config["config_pathname"]), spath
                    )
                )
                if not self.test_nsonly("-e", spath):
                    self.cmd_raises_nsonly(f"mkdir -p {spath}")
                args.append((spath, s[1], ""))

        for m in self.config.get("mounts", []):
            src = m.get("src", m.get("source", ""))
            if src:
                src = os.path.abspath(
                    os.path.join(
                        os.path.dirname(self.unet.config["config_pathname"]), src
                    )
                )
                if not self.test_nsonly("-e", src):
                    self.cmd_raises_nsonly(f"mkdir -p {src}")
            dst = m.get("dst", m.get("destination"))
            assert dst, "destination path required for mount"

            margs = []
            for k, v in m.items():
                if k in ["destination", "dst", "source", "src"]:
                    continue
                if k == "type":
                    assert v in ["bind", "tmpfs"]
                    continue
                if not v:
                    margs.append(k)
                else:
                    margs.append(f"{k}={v}")
            args.append((src, dst, ",".join(margs)))

        if args:
            self.extra_mounts += args

    async def _run_cmd(self, cmd_node):
        """Run the configured commands for this node inside VM."""
        self.logger.debug(
            "[rundir %s exists %s]", self.rundir, os.path.exists(self.rundir)
        )

        cmd = self.config.get(cmd_node, "").strip()
        if not cmd:
            self.logger.debug("%s: no `%s` to run", self, cmd_node)
            return None

        shell_cmd = self.config.get("shell", "/bin/bash")
        if not isinstance(shell_cmd, str):
            if shell_cmd:
                shell_cmd = "/bin/bash"
            else:
                shell_cmd = ""

        if shell_cmd:
            cmd = cmd.rstrip()
            cmd = f"#!{shell_cmd}\n" + cmd
            cmd = cmd.replace("%CONFIGDIR%", str(self.unet.config_dirname))
            cmd = cmd.replace("%RUNDIR%", str(self.rundir))
            cmd = cmd.replace("%NAME%", str(self.name))
            cmd += "\n"

            # Write a copy to the rundir
            cmdpath = os.path.join(self.rundir, f"{cmd_node}.shebang")
            with open(cmdpath, mode="w+", encoding="utf-8") as cmdfile:
                cmdfile.write(cmd)
            commander.cmd_raises(f"chmod 755 {cmdpath}")

            # Now write a copy inside the VM
            self.conrepl.cmd_status(
                f"cat > /tmp/{cmd_node}.shebang << EOF\n" + cmd + "\nEOF"
            )
            self.conrepl.cmd_status(f"chmod 755 /tmp/{cmd_node}.shebang")
            cmds = f"/tmp/{cmd_node}.shebang"
        else:
            cmd = cmd.replace("%CONFIGDIR%", str(self.unet.config_dirname))
            cmd = cmd.replace("%RUNDIR%", str(self.rundir))
            cmd = cmd.replace("%NAME%", str(self.name))
            cmds = cmd

        # class future_proc:
        #     """Treat awaitable minimally as a proc."""
        #     def __init__(self, aw):
        #         self.aw = aw
        #         # XXX would be nice to have a real value here
        #         self.returncode = 0
        #     async def wait(self):
        #         if self.aw:
        #             return await self.aw
        #         return None

        class now_proc:
            """Treat awaitable minimally as a proc."""

            def __init__(self, output):
                self.output = output
                self.returncode = 0

            async def wait(self):
                return self.output

        if self.cmdrepl:
            # self.cmd_p = future_proc(
            #     # We need our own console here b/c this is async and not returning
            #     # immediately
            #     # self.cmdrepl.run_command(cmds, timeout=120, async_=True)
            #     self.cmdrepl.run_command(cmds, timeout=120)
            # )

            # When run_command supports async_ arg we can use the above...
            self.cmd_p = now_proc(self.cmdrepl.run_command(cmds, timeout=120))
        else:
            # If we only have a console we can't run in parallel, so run to completion
            self.cmd_p = now_proc(self.conrepl.run_command(cmds, timeout=120))

        return self.cmd_p

    async def run_cmd(self):
        if self.disk_created:
            await self._run_cmd("initial-cmd")
        await self._run_cmd("cmd")

        # stdout and err both combined into logfile from the spawned repl
        if self.cmdrepl:
            stdout = os.path.join(self.rundir, "_cmdcon-log.txt")
            self.pytest_hook_run_cmd(stdout, None)

    # InterfaceMixin override
    # We need a name unique in the shared namespace.
    def get_ns_ifname(self, ifname):
        ifname = self.name + ifname
        ifname = re.sub("gigabitethernet", "GE", ifname, flags=re.I)
        if len(ifname) >= 16:
            ifname = ifname[0:7] + ifname[-8:]
        return ifname

    async def add_host_intf(self, hname, lname, mtu=None):
        # L3QemuVM needs it's own add_host_intf for macvtap, We need to create the tap
        # in the host then move that interface so that the ifindex/devfile are
        # different.

        if hname in self.host_intfs:
            return

        self.host_intfs[hname] = lname
        index = len(self.host_intfs)

        tapindex = self.unet.tapcount
        self.unet.tapcount = self.unet.tapcount + 1

        tapname = f"tap{tapindex}"
        self.tapnames[hname] = tapname

        mac = f"02:bb:bb:bb:{index:02x}:{self.id:02x}"
        self.tapmacs[hname] = mac

        self.unet.rootcmd.cmd_raises(
            f"ip link add link {hname} name {tapname} type macvtap"
        )
        if mtu:
            self.unet.rootcmd.cmd_raises(f"ip link set {tapname} mtu {mtu}")
        self.unet.rootcmd.cmd_raises(f"ip link set {tapname} address {mac} up")
        ifindex = self.unet.rootcmd.cmd_raises(
            f"cat /sys/class/net/{tapname}/ifindex"
        ).strip()
        # self.unet.rootcmd.cmd_raises(f"ip link set {tapname} netns {self.pid}")

        tapfile = f"/dev/tap{ifindex}"
        fd = os.open(tapfile, os.O_RDWR)
        self.tapfds[hname] = fd
        self.logger.info(
            "%s: Add host intf: created macvtap interface %s (%s) on %s fd %s",
            self,
            tapname,
            tapfile,
            hname,
            fd,
        )

    async def rem_host_intf(self, hname):
        tapname = self.tapnames[hname]
        self.unet.rootcmd.cmd_raises(f"ip link set {tapname} down")
        self.unet.rootcmd.cmd_raises(f"ip link delete {tapname} type macvtap")
        del self.tapnames[hname]
        del self.host_intfs[hname]

    async def create_tap(self, index, ifname, mtu=None, driver="virtio-net-pci"):
        # XXX we shouldn't be doign a tap on a bridge with a veth
        # we should just be using a tap created earlier which was connected to the
        # bridge. Except we need to handle the case of p2p qemu <-> namespace
        #
        ifname = self.get_ns_ifname(ifname)
        brname = f"{self.name}br{index}"

        tapindex = self.unet.tapcount
        self.unet.tapcount += 1

        mac = f"02:aa:aa:aa:{index:02x}:{self.id:02x}"
        # nic = "tap,model=virtio-net-pci"
        # qemu -net nic,model=virtio,addr=1a:46:0b:ca:bc:7b -net tap,fd=3 3<>/dev/tap11
        self.cmd_raises(f"ip address flush dev {ifname}")
        self.cmd_raises(f"ip tuntap add tap{tapindex} mode tap")
        self.cmd_raises(f"ip link add name {brname} type bridge")
        self.cmd_raises(f"ip link set dev {ifname} master {brname}")
        self.cmd_raises(f"ip link set dev tap{tapindex} master {brname}")
        if mtu:
            self.cmd_raises(f"ip link set dev tap{tapindex} mtu {mtu}")
            self.cmd_raises(f"ip link set dev {ifname} mtu {mtu}")
        self.cmd_raises(f"ip link set dev tap{tapindex} up")
        self.cmd_raises(f"ip link set dev {ifname} up")
        self.cmd_raises(f"ip link set dev {brname} up")
        dev = f"{driver},netdev=n{index},mac={mac}"
        return [
            "-netdev",
            f"tap,id=n{index},ifname=tap{tapindex},script=no,downscript=no",
            "-device",
            dev,
        ]

    async def mount_mounts(self):
        """Mount any shared directories."""
        self.logger.info("Mounting shared directories")
        con = self.conrepl
        for i, m in enumerate(self.extra_mounts):
            outer, mp, uargs = m
            if not outer:
                con.cmd_raises(f"mkdir -p {mp}")
                margs = f"-o {uargs}" if uargs else ""
                con.cmd_raises(f"mount {margs} -t tmpfs tmpfs {mp}")
                continue

            uargs = "" if uargs is None else uargs
            margs = "trans=virtio"
            if uargs:
                margs += f",{uargs}"
            self.logger.info("Mounting %s on %s with %s", outer, mp, margs)
            con.cmd_raises(f"mkdir -p {mp}")
            con.cmd_raises(f"mount -t 9p -o {margs} shared{i} {mp}")

    async def renumber_interfaces(self):
        """Re-number the interfaces.

        After VM comes up need to renumber the interfaces now on the inside.
        """
        self.logger.info("Renumbering interfaces")
        con = self.conrepl
        con.cmd_raises("sysctl -w net.ipv4.ip_forward=1")
        if self.unet.ipv6_enable:
            self.cmd_raises("sysctl -w net.ipv6.conf.all.forwarding=1")
        for ifname in sorted(self.intfs):
            conn = find_with_kv(self.config.get("connections"), "name", ifname)
            to = conn["to"]
            switch = self.unet.switches.get(to)
            mtu = conn.get("mtu")
            if not mtu and switch:
                mtu = switch.config.get("mtu")
            if mtu:
                con.cmd_raises(f"ip link set {ifname} mtu {mtu}")
            con.cmd_raises(f"ip link set {ifname} up")
            # In case there was some preconfig e.g., cloud-init
            con.cmd_raises(f"ip -4 addr flush dev {ifname}")
            sw_is_nat = switch and hasattr(switch, "is_nat") and switch.is_nat
            if ifaddr := self.get_intf_addr(ifname, ipv6=False):
                con.cmd_raises(f"ip addr add {ifaddr} dev {ifname}")
                if sw_is_nat:
                    # In case there was some preconfig e.g., cloud-init
                    con.cmd_raises("ip route flush exact default")
                    con.cmd_raises(f"ip route add default via {switch.ip_address}")
            if ifaddr := self.get_intf_addr(ifname, ipv6=True):
                con.cmd_raises(f"ip -6 addr add {ifaddr} dev {ifname}")
                if sw_is_nat:
                    # In case there was some preconfig e.g., cloud-init
                    con.cmd_raises("ip -6 route flush exact default")
                    con.cmd_raises(f"ip -6 route add default via {switch.ip6_address}")
        con.cmd_raises("ip link set lo up")

        # This is already mounted now
        # if self.unet.cfgopt.getoption("--coverage"):
        #     con.cmd_raises("mount -t debugfs none /sys/kernel/debug")

    async def gather_coverage_data(self):
        con = self.conrepl
        gcda_root = "/sys/kernel/debug/gcov"
        dest = "/tmp/gcov-data.tgz"

        if gcda_root != "/sys/kernel/debug/gcov":
            con.cmd_raises(
                rf"cd {gcda_root} && find * -name '*.gc??' "
                "| tar -cf - -T - | gzip -c > {dest}"
            )
        else:
            # Some tars dont try and read 0 length files so we need to copy them.
            tmpdir = con.cmd_raises("mktemp -d").strip()
            con.cmd_raises(
                rf"cd {gcda_root} && find -type d -exec mkdir -p {tmpdir}/{{}} \;"
            )
            con.cmd_raises(
                rf"cd {gcda_root} && "
                rf"find -name '*.gcda' -exec sh -c 'cat < $0 > {tmpdir}/$0' {{}} \;"
            )
            con.cmd_raises(
                rf"cd {gcda_root} && "
                rf"find -name '*.gcno' -exec sh -c 'cp -d $0 {tmpdir}/$0' {{}} \;"
            )
            con.cmd_raises(
                rf"cd {tmpdir} && "
                rf"find * -name '*.gc??' | tar -cf - -T - | gzip -c > {dest}"
            )
            con.cmd_raises(rf"rm -rf {tmpdir}")

        self.logger.debug("Saved coverage data in VM at %s", dest)
        ldest = os.path.join(self.rundir, "gcov-data.tgz")
        if self.use_ssh:
            self.cmd_raises(["/bin/cat", dest], stdout=open(ldest, "wb"))
            self.logger.debug("Saved coverage data on host at %s", ldest)
        else:
            output = con.cmd_raises(rf"base64 {dest}")
            with open(ldest, "wb") as f:
                f.write(base64.b64decode(output))
            self.logger.debug("Saved coverage data on host at %s", ldest)
        self.logger.info("Extracting coverage for %s into %s", self.name, ldest)

        # We need to place the gcda files where munet expects to find them
        gcdadir = Path(os.environ["GCOV_PREFIX"]) / self.name
        self.unet.cmd_raises_nsonly(f"mkdir -p {gcdadir}")
        self.unet.cmd_raises_nsonly(f"tar -C {gcdadir} -xzf {ldest}")

    async def _opencons(
        self,
        *cnames,
        prompt=None,
        is_bourne=True,
        user="root",
        password="",
        expects=None,
        sends=None,
        timeout=-1,
    ):
        """Open consoles based on socket file names."""
        timeo = Timeout(timeout)
        cons = []
        for cname in cnames:
            sockpath = os.path.join(self.sockdir, cname)
            connected = False
            sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            while self.launch_p.returncode is None and not timeo.is_expired():
                try:
                    sock.connect(sockpath)
                    connected = True
                    break
                except OSError as error:
                    if error.errno == errno.ENOENT:
                        self.logger.debug("waiting for console socket: %s", sockpath)
                    else:
                        self.logger.warning(
                            "can't open console socket: %s", error.strerror
                        )
                        raise
                elapsed = int(timeo.elapsed())
                if elapsed <= 3:
                    await asyncio.sleep(0.25)
                else:
                    self.logger.info(
                        "%s: launch (qemu) taking more than %ss", self, elapsed
                    )
                    await asyncio.sleep(1)

            if connected:
                if prompt is None:
                    prompt = r"(^|\r\n)[^#\$]*[#\$] "
                cons.append(
                    await self.console(
                        sock,
                        prompt=prompt,
                        is_bourne=is_bourne,
                        user=user,
                        password=password,
                        use_pty=False,
                        logfile_prefix=cname,
                        will_echo=True,
                        expects=expects,
                        sends=sends,
                        timeout=timeout,
                        init_newline=True,
                        trace=True,
                    )
                )
            elif self.launch_p.returncode is not None:
                self.logger.warning(
                    "%s: launch (qemu) exited quickly (%ss) rc: %s",
                    self,
                    timeo.elapsed(),
                    self.launch_p.returncode,
                )
                raise Exception("Qemu launch exited early")
            elif timeo.is_expired():
                self.logger.critical(
                    "%s: timeout (%ss) waiting for qemu to start",
                    self,
                    timeo.elapsed(),
                )
                assert not timeo.is_expired()

        return cons

    async def set_cpu_affinity(self, afflist):
        for i, aff in enumerate(afflist):
            if not aff:
                continue
            # affmask = convert_ranges_to_bitmask(aff)
            if i not in self.cpu_thread_map:
                logging.warning("affinity %s given for missing vcpu %s", aff, i)
                continue
            logging.info("setting vcpu %s affinity to %s", i, aff)
            tid = self.cpu_thread_map[i]
            self.cmd_raises_nsonly(f"taskset -cp {aff} {tid}")

    def _gen_network_config(self):
        intfs = sorted(self.intfs)
        if not intfs:
            return ""

        self.logger.debug("Generating cloud-init interface config")
        config = {}
        config["version"] = 2
        enets = config["ethernets"] = {}

        for ifname in sorted(self.intfs):
            self.logger.debug("Interface %s", ifname)
            conn = find_with_kv(self.config["connections"], "name", ifname)

            index = self.config["connections"].index(conn)
            to = conn["to"]
            switch = self.unet.switches.get(to)
            mtu = conn.get("mtu")
            if not mtu and switch:
                mtu = switch.config.get("mtu")

            devaddr = conn.get("physical", "")
            # Eventually we should get the MAC from /sys
            if not devaddr:
                mac = self.tapmacs.get(ifname, f"02:aa:aa:aa:{index:02x}:{self.id:02x}")
                nic = {
                    "match": {"macaddress": str(mac)},
                    "set-name": ifname,
                }
                if mtu:
                    nic["mtu"] = str(mtu)
                enets[f"nic-{ifname}"] = nic

            ifaddr4 = self.get_intf_addr(ifname, ipv6=False)
            ifaddr6 = self.get_intf_addr(ifname, ipv6=True)
            if not ifaddr4 and not ifaddr6:
                continue
            net = {
                "dhcp4": False,
                "dhcp6": False,
                "accept-ra": False,
                "addresses": [],
            }
            if ifaddr4:
                net["addresses"].append(str(ifaddr4))
            if ifaddr6:
                net["addresses"].append(str(ifaddr6))
            if switch and hasattr(switch, "is_nat") and switch.is_nat:
                net["nameservers"] = {"addresses": []}
                nameservers = net["nameservers"]["addresses"]
                if hasattr(switch, "ip6_address"):
                    net["gateway6"] = str(switch.ip6_address)
                    nameservers.append("2001:4860:4860::8888")
                if switch.ip_address:
                    net["gateway4"] = str(switch.ip_address)
                    nameservers.append("8.8.8.8")
            enets[ifname] = net

        return yaml.safe_dump(config)

    def _gen_cloud_init(self):
        qc = self.qemu_config
        cc = qc.get("console", {})
        cipath = self.rundir.joinpath("cloud-init.img")

        geniso = get_exec_path_host("genisoimage")
        if not geniso:
            mfbin = get_exec_path_host("mkfs.vfat")
            mcbin = get_exec_path_host("mcopy")
            assert (
                mfbin and mcbin
            ), "genisoimage or mkfs.vfat,mcopy needed to gen cloud-init disk"

        #
        # cloud-init: meta-data
        #
        mdata = f"""
instance-id: "munet-{self.id}"
local-hostname: "{self.name}"
"""
        #
        # cloud-init: user-data
        #
        ssh_auth_s = ""
        if bool(self.ssh_keyfile):
            pubkey = commander.cmd_raises(f"ssh-keygen -y -f {self.ssh_keyfile}")
            assert pubkey, f"Can't extract public key from {self.ssh_keyfile}"
            pubkey = pubkey.strip()
            ssh_auth_s = f'ssh_authorized_keys: ["{pubkey}"]'

        user = cc.get("user", "root")
        password = cc.get("password", "admin")
        if user != "root":
            root_password = "admin"
        else:
            root_password = password

        udata = f"""#cloud-config
disable_root: 0
ssh_pwauth: 1
hostname: {self.name}
runcmd:
  - systemctl enable serial-getty@ttyS1.service
  - systemctl start serial-getty@ttyS1.service
  - systemctl enable serial-getty@ttyS2.service
  - systemctl start serial-getty@ttyS2.service
  - systemctl enable serial-getty@hvc0.service
  - systemctl start serial-getty@hvc0.service
  - systemctl enable serial-getty@hvc1.service
  - systemctl start serial-getty@hvc1.service
users:
  - name: root
    lock_passwd: false
    plain_text_passwd: "{root_password}"
    {ssh_auth_s}
"""
        if user != "root":
            udata += """
  - name: {user}
    lock_passwd: false
    plain_text_passwd: "{password}"
    {ssh_auth_s}
"""
        #
        # cloud-init: network-config
        #
        ndata = self._gen_network_config()

        #
        # Generate cloud-init files
        #
        cidir = self.rundir.joinpath("ci-data")
        commander.cmd_raises(f"mkdir -p {cidir}")

        with open(cidir.joinpath("meta-data"), "w+", encoding="utf-8") as f:
            f.write(mdata)
        with open(cidir.joinpath("user-data"), "w+", encoding="utf-8") as f:
            f.write(udata)
        files = "meta-data user-data"
        if ndata:
            files += " network-config"
            with open(cidir.joinpath("network-config"), "w+", encoding="utf-8") as f:
                f.write(ndata)
        if geniso:
            commander.cmd_raises(
                f"cd {cidir} && "
                f'genisoimage -output "{cipath}" -volid cidata'
                f" -joliet -rock {files}"
            )
        else:
            commander.cmd_raises(f'cd {cidir} && mkfs.vfat -n cidata "{cipath}"')
            commander.cmd_raises(f'cd {cidir} && mcopy -oi "{cipath}" {files}')

        #
        # Generate cloud-init disk
        #
        return cipath

    async def launch(self):
        """Launch qemu."""
        self.logger.info("%s: Launch Qemu", self)

        qc = self.qemu_config
        cc = qc.get("console", {})
        bootd = "d" if "iso" in qc else "c"
        # args = [get_exec_path_host("qemu-system-x86_64"),
        #         "-nodefaults", "-boot", bootd]
        args = [get_exec_path_host("qemu-system-x86_64"), "-boot", bootd]

        args += ["-machine", "q35"]

        if qc.get("kvm"):
            rc, _, e = await self.async_cmd_status_nsonly("ls -l /dev/kvm")
            if rc:
                self.logger.warning("Can't enable KVM no /dev/kvm: %s", e)
            else:
                # [args += ["-enable-kvm", "-cpu", "host"]
                # uargs += ["-accel", "kvm", "-cpu", "Icelake-Server-v5"]
                args += ["-accel", "kvm", "-cpu", "host"]

        if ncpu := qc.get("ncpu"):
            # args += ["-smp", f"sockets={ncpu}"]
            args += ["-smp", f"cores={ncpu}"]
            # args += ["-smp", f"{ncpu},sockets={ncpu},cores=1,threads=1"]

        args.extend(["-m", str(qc.get("memory", "512M"))])

        if "bios" in qc:
            if qc["bios"] == "open-firmware":
                args.extend(["-bios", "/usr/share/qemu/OVMF.fd"])
            else:
                args.extend(["-bios", qc["bios"]])
        if "kernel" in qc:
            args.extend(["-kernel", qc["kernel"]])
        if "initrd" in qc:
            args.extend(["-initrd", qc["initrd"]])
        if "iso" in qc:
            args.extend(["-cdrom", qc["iso"]])

        # we only have append if we have a kernel
        if "kernel" in qc:
            args.append("-append")
            root = qc.get("root", "/dev/ram0")
            # Only 1 serial console the other ports (ttyS[123] hvc[01]) should have
            # gettys in inittab
            append = f"root={root} rw console=ttyS0"
            if "cmdline-extra" in qc:
                append += f" {qc['cmdline-extra']}"
            args.append(append)

        if "extra-args" in qc:
            if isinstance(qc["extra-args"], list):
                args.extend(qc["extra-args"])
            else:
                args.extend(shlex.split(qc["extra-args"]))

        # Walk the list of connections in order so we attach them the same way
        pass_fds = []
        nnics = 0
        pciaddr = 3
        for index, conn in enumerate(self.config["connections"]):
            devaddr = conn.get("physical", "")
            hostintf = conn.get("hostintf", "")
            if devaddr:
                # if devaddr in self.tapmacs:
                #     mac = f",mac={self.tapmacs[devaddr]}"
                # else:
                #     mac = ""
                args += ["-device", f"vfio-pci,host={devaddr},addr={pciaddr}"]
            elif hostintf:
                fd = self.tapfds[hostintf]
                mac = self.tapmacs[hostintf]
                args += [
                    "-nic",
                    f"tap,model=virtio-net-pci,mac={mac},fd={fd},addr={pciaddr}",
                ]
                pass_fds.append(fd)
                nnics += 1
            elif not hostintf:
                driver = conn.get("driver", "virtio-net-pci")
                mtu = conn.get("mtu")
                if not mtu and conn["to"] in self.unet.switches:
                    mtu = self.unet.switches[conn["to"]].config.get("mtu")
                tapargs = await self.create_tap(
                    index, conn["name"], mtu=mtu, driver=driver
                )
                tapargs[-1] += f",addr={pciaddr}"
                args += tapargs
                nnics += 1
            pciaddr += 1
        if not nnics:
            args += ["-nic", "none"]

        dtplpath = dtpl = qc.get("disk-template")
        diskpath = disk = qc.get("disk")
        if diskpath:
            if diskpath[0] != "/":
                diskpath = os.path.join(self.unet.config_dirname, diskpath)

        if dtpl and (not disk or not os.path.exists(diskpath)):
            basename = os.path.basename(dtpl)
            confdir = self.unet.config_dirname
            if re.match("(https|http|ftp|tftp):.*", dtpl):
                await self.unet.async_cmd_raises_once(
                    f"cd {confdir} && (test -e {basename} || curl -fLO {dtpl})"
                )
                dtplpath = os.path.join(confdir, basename)

            if not disk:
                disk = qc["disk"] = f"{self.name}-{basename}"
                diskpath = os.path.join(self.rundir, disk)

            if self.path_exists(diskpath):
                logging.debug("Disk '%s' file exists, using.", diskpath)

            else:
                if dtplpath[0] != "/":
                    dtplpath = os.path.join(self.unet.config_dirname, dtpl)
                logging.info("Create disk '%s' from template '%s'", diskpath, dtplpath)
                self.cmd_raises(
                    f"qemu-img create -f qcow2 -F qcow2 -b {dtplpath} {diskpath}"
                )
                self.disk_created = True

        disk_driver = qc.get("disk-driver", "virtio")
        if diskpath:
            if disk_driver == "virtio":
                args.extend(["-drive", f"file={diskpath},if=virtio,format=qcow2"])
            else:
                args.extend(
                    ["-drive", f"file={diskpath},if=none,id=sata-disk0,format=qcow2"]
                )
                args.extend(["-device", "ahci,id=ahci"])
                args.extend(["-device", "ide-hd,bus=ahci.0,drive=sata-disk0"])

        if qc.get("cloud-init"):
            cidiskpath = qc.get("cloud-init-disk")
            if cidiskpath:
                if cidiskpath[0] != "/":
                    cidiskpath = os.path.join(self.unet.config_dirname, cidiskpath)
            else:
                cidiskpath = self._gen_cloud_init()
            diskfmt = "qcow2" if str(cidiskpath).endswith("qcow2") else "raw"
            args.extend(["-drive", f"file={cidiskpath},if=virtio,format={diskfmt}"])

        # args.extend(["-display", "vnc=0.0.0.0:40"])

        use_stdio = cc.get("stdio", True)
        has_cmd = self.config.get("cmd")
        use_cmdcon = has_cmd and use_stdio

        #
        # Any extra serial/console ports beyond thw first, require entries in
        # inittab to have getty running on them, modify inittab
        #
        # Use -serial stdio for output only, and as the first serial console
        # which kernel uses for printk, as it has serious issues with dropped
        # input chars for some reason.
        #
        # 4 serial ports (max), we'll add extra ports using virtual consoles.
        _sd = self.sockdir
        if use_stdio:
            args += ["-serial", "stdio"]
        args += ["-serial", f"unix:{_sd}/_console,server,nowait"]
        if use_cmdcon:
            args += [
                "-serial",
                f"unix:{_sd}/_cmdcon,server,nowait",
            ]
        args += [
            "-serial",
            f"unix:{_sd}/console,server,nowait",
            # A 2 virtual consoles - /dev/hvc[01]
            # Requires CONFIG_HVC_DRIVER=y CONFIG_VIRTIO_CONSOLE=y
            "-device",
            "virtio-serial",  # serial console bus
            "-chardev",
            f"socket,path={_sd}/vcon0,server=on,wait=off,id=vcon0",
            "-chardev",
            f"socket,path={_sd}/vcon1,server=on,wait=off,id=vcon1",
            "-device",
            "virtconsole,chardev=vcon0",
            "-device",
            "virtconsole,chardev=vcon1",
            # 2 monitors
            "-monitor",
            f"unix:{_sd}/_monitor,server,nowait",
            "-monitor",
            f"unix:{_sd}/monitor,server,nowait",
            "-gdb",
            f"unix:{_sd}/gdbserver,server,nowait",
        ]

        for i, m in enumerate(self.extra_mounts):
            args += [
                "-virtfs",
                f"local,path={m[0]},mount_tag=shared{i},security_model=passthrough",
            ]

        args += ["-nographic"]

        #
        # Launch Qemu
        #

        stdout = open(os.path.join(self.rundir, "qemu.out"), "wb")
        stderr = open(os.path.join(self.rundir, "qemu.err"), "wb")
        self.launch_p = await self.async_popen_nsonly(
            args,
            stdin=subprocess.DEVNULL,
            stdout=stdout,
            stderr=stderr,
            pass_fds=pass_fds,
            # Don't want Keybaord interrupt etc to pass to child.
            # start_new_session=True,
            preexec_fn=os.setsid,
        )

        if self.nsenter_fork:
            self.launch_pid = await self.get_proc_child_pid(self.launch_p)

        self.pytest_hook_run_cmd(stdout, stderr)

        # We've passed these on, so don't need these open here anymore.
        for fd in pass_fds:
            os.close(fd)

        self.logger.debug(
            "%s: popen => %s (%s)", self, self.launch_p.pid, self.launch_pid
        )

        confiles = ["_console"]
        if use_cmdcon:
            confiles.append("_cmdcon")

        password = cc.get("password", "admin")
        if self.disk_created:
            password = cc.get("initial-password", password)

        #
        # Connect to the console socket, retrying
        #
        prompt = cc.get("prompt")
        cons = await self._opencons(
            *confiles,
            prompt=prompt,
            is_bourne=not bool(prompt),
            user=cc.get("user", "root"),
            password=password,
            expects=cc.get("expects"),
            sends=cc.get("sends"),
            timeout=int(cc.get("timeout", 60)),
        )
        self.conrepl = cons[0]
        if use_cmdcon:
            self.cmdrepl = cons[1]
        self.monrepl = await self.monitor(os.path.join(self.sockdir, "_monitor"))

        # the monitor output has super annoying ANSI escapes in it

        output = self.monrepl.cmd_nostatus("info status")
        self.logger.debug("VM status: %s", output)

        output = self.monrepl.cmd_nostatus("info kvm")
        self.logger.debug("KVM status: %s", output)

        #
        # Set thread affinity
        #
        output = self.monrepl.cmd_nostatus("info cpus")
        matches = re.findall(r"CPU #(\d+): *thread_id=(\d+)", output)
        self.cpu_thread_map = {int(k): int(v) for k, v in matches}
        if cpuaff := self.qemu_config.get("cpu-affinity"):
            await self.set_cpu_affinity(cpuaff)

        self.is_kvm = "disabled" not in output

        if qc.get("unix-os", True):
            await self.renumber_interfaces()

        if self.extra_mounts:
            await self.mount_mounts()

        self.use_ssh = bool(self.ssh_keyfile)
        if self.use_ssh:
            self.use_ssh = self.__setup_ssh()

        self.pytest_hook_open_shell()

        return self.launch_p

    def launch_completed(self, future):
        self.logger.debug("%s: launch (qemu) completed called", self)
        self.use_ssh = False
        try:
            n = future.result()
            self.logger.debug("%s: node launch (qemu) completed result: %s", self, n)
        except asyncio.CancelledError as error:
            self.logger.debug(
                "%s: node launch (qemu) cmd wait() canceled: %s", future, error
            )

    async def async_cleanup_cmd(self):
        """Run the configured cleanup commands for this node."""
        if self.cleanup_called:
            return
        self.cleanup_called = True

        if "cleanup-cmd" not in self.config:
            return

        if not self.launch_p:
            self.logger.warning("async_cleanup_cmd: qemu no longer running")
            return

        raise NotImplementedError("Needs to be like run_cmd")
        # return await self._async_cleanup_cmd()

    async def _async_delete(self):
        self.logger.debug("%s: deleting", self)

        # Need to cleanup early b/c it is running on the VM
        if self.cmd_p:
            await self.async_cleanup_proc(self.cmd_p, self.cmd_pid)
            self.cmd_p = None

        try:
            # Need to cleanup early b/c it is running on the VM
            if not self.cleanup_called:
                await self.async_cleanup_cmd()
        except Exception as error:
            self.logger.warning(
                "Got an error during delete from async_cleanup_cmd: %s", error
            )

        try:
            if not self.launch_p:
                self.logger.warning("async_delete: qemu is not running")
            else:
                await self.async_cleanup_proc(self.launch_p, self.launch_pid)
        except Exception as error:
            self.logger.warning("%s: failed to cleanup qemu process: %s", self, error)

        await super()._async_delete()


class Munet(BaseMunet):
    """Munet."""

    def __init__(
        self,
        rundir=None,
        config=None,
        pid=True,
        logger=None,
        **kwargs,
    ):
        # logging.warning("Munet")

        if not rundir:
            rundir = "/tmp/munet"

        if logger is None:
            logger = logging.getLogger("munet.unet")

        super().__init__("munet", pid=pid, rundir=rundir, logger=logger, **kwargs)

        self.built = False
        self.tapcount = 0

        self.cmd_raises(f"mkdir -p {self.rundir} && chmod 755 {self.rundir}")
        self.set_ns_cwd(self.rundir)

        if not config:
            config = {}
        self.config = config
        if "config_pathname" in config:
            self.config_pathname = os.path.realpath(config["config_pathname"])
            self.config_dirname = os.path.dirname(self.config_pathname)
        else:
            self.config_pathname = ""
            self.config_dirname = ""

        # Done in BaseMunet now
        # # We need some way to actually get back to the root namespace
        # if not self.isolated:
        #     self.rootcmd = commander
        # else:
        #     spid = str(pid)
        #     nsflags = (f"--mount={self.proc_path / spid / 'ns/mnt'}",
        #                f"--net={self.proc_path / spid / 'ns/net'}",
        #                f"--uts={self.proc_path / spid / 'ns/uts'}",
        #                f"--ipc={self.proc_path / spid / 'ns/ipc'}",
        #                f"--cgroup={self.proc_path / spid / 'ns/cgroup'}",
        #                f"--pid={self.proc_path / spid / 'ns/net'}",
        #     self.rootcmd = SharedNamespace("host", pid=1, nsflags=nsflags)

        # Save the namespace pid
        with open(os.path.join(self.rundir, "nspid"), "w", encoding="ascii") as f:
            f.write(f"{self.pid}\n")

        with open(os.path.join(self.rundir, "nspids"), "w", encoding="ascii") as f:
            f.write(f'{" ".join([str(x) for x in self.pids])}\n')

        hosts_file = os.path.join(self.rundir, "hosts.txt")
        with open(hosts_file, "w", encoding="ascii") as hf:
            hf.write(
                f"""127.0.0.1\tlocalhost {self.name}
::1\tip6-localhost ip6-loopback
fe00::0\tip6-localnet
ff00::0\tip6-mcastprefix
ff02::1\tip6-allnodes
ff02::2\tip6-allrouters
"""
            )
        self.bind_mount(hosts_file, "/etc/hosts")

        # Common CLI commands for any topology
        cdict = {
            "commands": [
                {
                    "name": "pcap",
                    "format": "pcap NETWORK",
                    "help": (
                        "capture packets from NETWORK into file capture-NETWORK.pcap"
                        " the command is run within a new window which also shows"
                        " packet summaries. NETWORK can also be an interface specified"
                        " as HOST:INTF. To capture inside the host namespace."
                    ),
                    "exec": "tshark -s 9200 -i {0} -P -w capture-{0}.pcap",
                    "top-level": True,
                    "new-window": {"background": True},
                },
                {
                    "name": "nsterm",
                    "format": "nsterm HOST [HOST ...]",
                    "help": (
                        "open terminal[s] in the namespace only"
                        " (outside containers or VM), * for all"
                    ),
                    "exec": "bash",
                    "new-window": {"ns_only": True},
                },
                {
                    "name": "term",
                    "format": "term HOST [HOST ...]",
                    "help": "open terminal[s] (TMUX or XTerm) on HOST[S], * for all",
                    "exec": "bash",
                    "new-window": True,
                },
                {
                    "name": "xterm",
                    "format": "xterm HOST [HOST ...]",
                    "help": "open XTerm[s] on HOST[S], * for all",
                    "exec": "bash",
                    "new-window": {
                        "forcex": True,
                    },
                },
                {
                    "name": "sh",
                    "format": "[HOST ...] sh <SHELL-COMMAND>",
                    "help": "execute <SHELL-COMMAND> on hosts",
                    "exec": "{}",
                },
                {
                    "name": "shi",
                    "format": "[HOST ...] shi <INTERACTIVE-COMMAND>",
                    "help": "execute <INTERACTIVE-COMMAND> on HOST[s]",
                    "exec": "{}",
                    "interactive": True,
                },
                {
                    "name": "stdout",
                    "exec": (
                        "[ -e %RUNDIR%/qemu.out ] && tail -F %RUNDIR%/qemu.out "
                        "|| tail -F %RUNDIR%/cmd.out"
                    ),
                    "format": "stdout HOST [HOST ...]",
                    "help": "tail -f on the stdout of the qemu/cmd for this node",
                    "new-window": {"background": True, "ns_only": True},
                },
                {
                    "name": "stderr",
                    "exec": (
                        "[ -e %RUNDIR%/qemu.err ] && tail -F %RUNDIR%/qemu.err "
                        "|| tail -F %RUNDIR%/cmd.err"
                    ),
                    "format": "stderr HOST [HOST ...]",
                    "help": "tail -f on the stdout of the qemu/cmd for this node",
                    "new-window": {"background": True, "ns_only": True},
                },
            ]
        }

        cli.add_cli_config(self, cdict)

        if "cli" in config:
            cli.add_cli_config(self, config["cli"])

        if "topology" not in self.config:
            self.config["topology"] = {}

        self.topoconf = self.config["topology"]
        self.ipv6_enable = self.topoconf.get("ipv6-enable", False)

        if self.isolated:
            if not self.ipv6_enable:
                # Disable IPv6
                self.cmd_raises("sysctl -w net.ipv6.conf.all.autoconf=0")
                self.cmd_raises("sysctl -w net.ipv6.conf.all.disable_ipv6=1")
                self.cmd_raises("sysctl -w net.ipv6.conf.all.forwarding=0")
            else:
                self.cmd_raises("sysctl -w net.ipv6.conf.all.autoconf=1")
                self.cmd_raises("sysctl -w net.ipv6.conf.all.disable_ipv6=0")
                self.cmd_raises("sysctl -w net.ipv6.conf.all.forwarding=1")

        # we really need overlay, but overlay-layers (used by overlay-images)
        # counts on things being present in overlay so this temp stuff doesn't work.
        # if self.isolated:
        #     # Let's hide podman details
        #     self.tmpfs_mount("/var/lib/containers/storage/overlay-containers")

        def run_init_cmds(unet, key, on_host):
            cmds = unet.topoconf.get(key, "")
            cmds = cmds.replace("%CONFIGDIR%", str(unet.config_dirname))
            cmds = cmds.replace("%RUNDIR%", str(unet.rundir))
            cmds = cmds.strip()
            if not cmds:
                return

            cmds += "\n"
            c = commander if on_host else unet
            o = c.cmd_raises(cmds)
            self.logger.debug(
                "run_init_cmds (on-host: %s): %s", on_host, cmd_error(0, o, "")
            )

        run_init_cmds(self, "initial-setup-host-cmd", True)
        run_init_cmds(self, "initial-setup-cmd", False)

        shellopt = self.cfgopt.getoption("--shell")
        shellopt = shellopt if shellopt else ""
        if shellopt == "all" or "." in shellopt.split(","):
            self.run_in_window("bash")

    def __del__(self):
        """Catch case of build object but not async_deleted."""
        if hasattr(self, "built"):
            if not self.deleting:
                logging.critical(
                    "Munet object deleted without calling `async_delete` for cleanup."
                )
        s = super()
        if hasattr(s, "__del__"):
            s.__del__(self)

    async def _async_build(self, logger=None):
        """Build the topology based on config."""
        if self.built:
            self.logger.warning("%s: is already built", self)
            return

        self.built = True

        # Allow for all networks to be auto-numbered
        topoconf = self.topoconf
        autonumber = self.autonumber
        ipv6_enable = self.ipv6_enable

        # ---------------------------------------------
        # Merge Kinds and perform variable substitution
        # ---------------------------------------------

        kinds = self.config.get("kinds", {})

        for name, conf in config_to_dict_with_key(topoconf, "networks", "name").items():
            if kind := conf.get("kind"):
                if kconf := kinds[kind]:
                    conf = merge_kind_config(kconf, conf)
            conf = config_subst(
                conf, name=name, rundir=self.rundir, configdir=self.config_dirname
            )
            if "ip" not in conf and autonumber:
                conf["ip"] = "auto"
            if "ipv6" not in conf and autonumber and ipv6_enable:
                conf["ipv6"] = "auto"
            topoconf["networks"][name] = conf
            self.add_network(name, conf, logger=logger)

        for name, conf in config_to_dict_with_key(topoconf, "nodes", "name").items():
            if kind := conf.get("kind"):
                if kconf := kinds[kind]:
                    conf = merge_kind_config(kconf, conf)

            config_to_dict_with_key(
                conf, "env", "name"
            )  # convert list of env objects to dict

            conf = config_subst(
                conf,
                name=name,
                rundir=os.path.join(self.rundir, name),
                configdir=self.config_dirname,
            )
            topoconf["nodes"][name] = conf
            self.add_l3_node(name, conf, logger=logger)

        # ------------------
        # Create connections
        # ------------------

        # Go through all connections and name them so they are sane to the user
        # otherwise when we do p2p links the names/ords skip around based oddly
        for name, node in self.hosts.items():
            nconf = node.config
            if "connections" not in nconf:
                continue
            nconns = []
            for cconf in nconf["connections"]:
                # Replace string only with a dictionary
                if isinstance(cconf, str):
                    splitconf = cconf.split(":", 1)
                    cconf = {"to": splitconf[0]}
                    if len(splitconf) == 2:
                        cconf["name"] = splitconf[1]
                # Allocate a name if not already assigned
                if "name" not in cconf:
                    cconf["name"] = node.get_next_intf_name()
                nconns.append(cconf)
            nconf["connections"] = nconns

        for name, node in self.hosts.items():
            nconf = node.config
            if "connections" not in nconf:
                continue
            for cconf in nconf["connections"]:
                # Eventually can add support for unconnected intf here.
                if "to" not in cconf:
                    continue
                to = cconf["to"]
                if to in self.switches:
                    switch = self.switches[to]
                    swconf = find_matching_net_config(name, cconf, switch.config)
                    await self.add_native_link(switch, node, swconf, cconf)
                elif cconf["name"] not in node.intfs:
                    # Only add the p2p interface if not already there.
                    other = self.hosts[to]
                    oconf = find_matching_net_config(name, cconf, other.config)
                    await self.add_native_link(node, other, cconf, oconf)

    @property
    def autonumber(self):
        return self.topoconf.get("networks-autonumber", False)

    @autonumber.setter
    def autonumber(self, value):
        self.topoconf["networks-autonumber"] = bool(value)

    async def add_native_link(self, node1, node2, c1=None, c2=None):
        """Add a link between switch and node or 2 nodes."""
        isp2p = False

        c1 = {} if c1 is None else c1
        c2 = {} if c2 is None else c2

        if node1.name in self.switches:
            assert node2.name in self.hosts
        elif node2.name in self.switches:
            assert node1.name in self.hosts
            node1, node2 = node2, node1
            c1, c2 = c2, c1
        else:
            # p2p link
            assert node1.name in self.hosts
            assert node1.name in self.hosts
            isp2p = True

        if "name" not in c1:
            c1["name"] = node1.get_next_intf_name()
        if1 = c1["name"]

        if "name" not in c2:
            c2["name"] = node2.get_next_intf_name()
        if2 = c2["name"]

        do_add_link = True
        for n, c in ((node1, c1), (node2, c2)):
            if "hostintf" in c:
                await n.add_host_intf(c["hostintf"], c["name"], mtu=c.get("mtu"))
                do_add_link = False
            elif "physical" in c:
                await n.add_phy_intf(c["physical"], c["name"])
                do_add_link = False
        if do_add_link:
            assert "hostintf" not in c1
            assert "hostintf" not in c2
            assert "physical" not in c1
            assert "physical" not in c2

            if isp2p:
                mtu1 = c1.get("mtu")
                mtu2 = c2.get("mtu")
                mtu = mtu1 if mtu1 else mtu2
                if mtu1 and mtu2 and mtu1 != mtu2:
                    self.logger.error("mtus differ for add_link %s != %s", mtu1, mtu2)
            else:
                mtu = c2.get("mtu")

            super().add_link(node1, node2, if1, if2, mtu=mtu)

        if isp2p:
            node1.set_p2p_addr(node2, c1, c2)
        else:
            node2.set_lan_addr(node1, c2)

        if isinstance(node1, ExternalNetwork):
            pass
        elif "physical" not in c1 and not node1.is_vm:
            node1.set_intf_constraints(if1, **c1)
        if "physical" not in c2 and not node2.is_vm:
            node2.set_intf_constraints(if2, **c2)

    def add_l3_node(self, name, config=None, **kwargs):
        """Add a node to munet."""
        if config and config.get("image"):
            cls = L3ContainerNode
        elif config and config.get("qemu"):
            cls = L3QemuVM
        elif config and config.get("server"):
            cls = SSHRemote
        elif config and config.get("hostnet"):
            cls = HostnetNode
        else:
            cls = L3NamespaceNode
        return super().add_host(name, cls=cls, config=config, **kwargs)

    def add_network(self, name, config=None, **kwargs):
        """Add a l2 or l3 switch to munet."""
        if config is None:
            config = {}

        if config.get("external"):
            cls = ExternalNetwork
        elif config.get("ip"):
            cls = L3Bridge
        else:
            cls = L2Bridge
        mtu = kwargs.get("mtu", config.get("mtu"))
        return super().add_switch(name, cls=cls, config=config, mtu=mtu, **kwargs)

    def coverage_setup(self):
        bdir = self.cfgopt.getoption("--cov-build-dir")
        if not bdir:
            # Try and find the build dir using common prefix of gcno files
            common = None
            cwd = os.getcwd()
            for f in glob.iglob(rf"{cwd}/**/*.gcno", recursive=True):
                if not common:
                    common = os.path.dirname(f)
                else:
                    common = os.path.commonprefix([common, f])
                    if not common:
                        break
        assert (
            bdir
        ), "Can't locate build directory for coverage data, use --cov-build-dir"

        bdir = Path(bdir).resolve()
        rundir = Path(self.rundir).resolve()
        gcdadir = rundir / "gcda"
        os.environ["GCOV_BUILD_DIR"] = str(bdir)
        os.environ["GCOV_PREFIX_STRIP"] = str(len(bdir.parts) - 1)
        os.environ["GCOV_PREFIX"] = str(gcdadir)

        # commander.cmd_raises(f"find {bdir} -name '*.gc??' -exec chmod o+rw {{}} +")
        group_id = bdir.stat().st_gid
        commander.cmd_raises(f"mkdir -p {gcdadir}")
        commander.cmd_raises(f"chown -R root:{group_id} {gcdadir}")
        commander.cmd_raises(f"chmod 2775 {gcdadir}")

    async def coverage_finish(self):
        rundir = Path(self.rundir).resolve()
        bdir = Path(os.environ["GCOV_BUILD_DIR"])
        gcdadir = Path(os.environ["GCOV_PREFIX"])

        # Create .gcno symlinks if they don't already exist, for kernel they will
        self.logger.info("Creating .gcno symlinks from '%s' to '%s'", gcdadir, bdir)
        commander.cmd_raises(
            f'cd "{gcdadir}"; bdir="{bdir}"'
            + """
for f in $(find . -name '*.gcda'); do
    f=${f#./};
    f=${f%.gcda}.gcno;
    if [ ! -h "$f" ]; then
        ln -fs $bdir/$f $f;
        touch -h -r $bdir/$f $f;
        echo $f;
    fi;
done"""
        )

        # Get the results into a summary file
        data_file = rundir / "coverage.info"
        self.logger.info("Gathering coverage data into: %s", data_file)
        commander.cmd_raises(
            f"lcov --directory {gcdadir} --capture --output-file {data_file}"
        )

        # Get coverage info filtered to a specific set of files
        report_file = rundir / "coverage.info"
        self.logger.debug("Generating coverage summary: %s", report_file)
        output = commander.cmd_raises(f"lcov --summary {data_file}")
        self.logger.info("\nCOVERAGE-SUMMARY-START\n%s\nCOVERAGE-SUMMARY-END", output)
        # terminalreporter.write(
        #     f"\nCOVERAGE-SUMMARY-START\n{output}\nCOVERAGE-SUMMARY-END\n"
        # )

    async def load_images(self, images):
        tasks = []
        for image in images:
            logging.debug("Checking for image %s", image)
            rc, _, _ = self.rootcmd.cmd_status(
                f"podman image inspect {image}", warn=False
            )
            if not rc:
                continue
            logging.info("Pulling missing image %s", image)

            aw = self.rootcmd.async_cmd_raises_once(f"podman pull {image}")
            tasks.append(asyncio.create_task(aw))
        if not tasks:
            return
        _, pending = await asyncio.wait(tasks, timeout=600)
        assert not pending, "Failed to pull container images"

    async def run(self):
        tasks = []
        hosts = self.hosts.values()

        images = {x.container_image for x in hosts if hasattr(x, "container_image")}
        await self.load_images(images)

        launch_nodes = [x for x in hosts if hasattr(x, "launch")]
        launch_nodes = [x for x in launch_nodes if x.config.get("qemu")]
        run_nodes = [x for x in hosts if x.has_run_cmd()]
        ready_nodes = [x for x in hosts if x.has_ready_cmd()]

        if self.cfgopt.getoption("--coverage"):
            self.coverage_setup()

        pcapopt = self.cfgopt.getoption("--pcap")
        pcapopt = pcapopt if pcapopt else ""
        if pcapopt == "all":
            pcapopt = self.switches.keys()
        if pcapopt:
            for pcap in pcapopt.split(","):
                if ":" in pcap:
                    host, intf = pcap.split(":")
                    pcap = f"{host}-{intf}"
                    host = self.hosts[host]
                else:
                    host = self
                    intf = pcap
                host.run_in_window(
                    f"tshark -s 9200 -i {intf} -P -w capture-{pcap}.pcap",
                    background=True,
                    title=f"cap:{pcap}",
                )

        if launch_nodes:
            # would like a info when verbose here.
            logging.debug("Launching nodes")
            await asyncio.gather(*[x.launch() for x in launch_nodes])

        logging.debug("Launched nodes -- Queueing Waits")

        # Watch for launched processes to exit
        for node in launch_nodes:
            task = asyncio.create_task(
                node.launch_p.wait(), name=f"Node-{node.name}-launch"
            )
            task.add_done_callback(node.launch_completed)
            tasks.append(task)

        logging.debug("Wait complete queued, running cmd")

        if run_nodes:
            # would like a info when verbose here.
            logging.debug("Running `cmd` on nodes")
            await asyncio.gather(*[x.run_cmd() for x in run_nodes])

        logging.debug("Ran cmds -- Queueing Waits")

        # Watch for run_cmd processes to exit
        for node in run_nodes:
            task = asyncio.create_task(node.cmd_p.wait(), name=f"Node-{node.name}-cmd")
            task.add_done_callback(node.cmd_completed)
            tasks.append(task)

        logging.debug("Wait complete queued, waiting for ready")

        # Wait for nodes to be ready
        if ready_nodes:

            async def wait_until_ready(x):
                while not await x.async_ready_cmd():
                    logging.debug("Waiting for ready on: %s", x)
                    await asyncio.sleep(0.25)
                logging.debug("%s is ready!", x)

            tasks = [asyncio.create_task(wait_until_ready(x)) for x in ready_nodes]

            logging.debug("Waiting for ready on nodes: %s", ready_nodes)
            _, pending = await asyncio.wait(tasks, timeout=30)
            if pending:
                logging.warning("Timeout waiting for ready: %s", pending)
                for nr in pending:
                    nr.cancel()
                raise asyncio.TimeoutError()
            logging.debug("All nodes ready")

        logging.debug("All done returning tasks: %s", tasks)

        return tasks

    async def _async_delete(self):
        from .testing.util import async_pause_test  # pylint: disable=C0415

        self.logger.debug("%s: deleting.", self)

        pause = bool(self.cfgopt.getoption("--pause-at-end"))
        pause = pause or bool(self.cfgopt.getoption("--pause"))
        if pause:
            try:
                await async_pause_test("Before MUNET delete")
            except KeyboardInterrupt:
                print("^C...continuing")
            except Exception as error:
                self.logger.error("\n...continuing after error: %s", error)

        # Run cleanup-cmd's.
        nodes = (x for x in self.hosts.values() if x.has_cleanup_cmd())
        try:
            await asyncio.gather(*(x.async_cleanup_cmd() for x in nodes))
        except Exception as error:
            logging.warning("Error running cleanup cmds: %s", error)

        # Gather any coverage data
        if self.cfgopt.getoption("--coverage"):
            nodes = (
                x for x in self.hosts.values() if hasattr(x, "gather_coverage_data")
            )
            try:
                await asyncio.gather(*(x.gather_coverage_data() for x in nodes))
            except Exception as error:
                logging.warning("Error gathering coverage data: %s", error)

            await self.coverage_finish()

        # XXX should we cancel launch and run tasks?

        try:
            await super()._async_delete()
        except Exception as error:
            self.logger.error("Error cleaning up: %s", error, exc_info=True)
            raise


async def run_cmd_update_ceos(node, shell_cmd, cmds, cmd):
    cmd = cmd.strip()
    if shell_cmd or cmd != "/sbin/init":
        return cmds, cmd

    #
    # Add flash dir and mount it
    #
    flashdir = os.path.join(node.rundir, "flash")
    node.cmd_raises_nsonly(f"mkdir -p {flashdir} && chmod 775 {flashdir}")
    cmds += [f"--volume={flashdir}:/mnt/flash"]

    #
    # Startup config (if not present already)
    #
    if startup_config := node.config.get("startup-config", None):
        dest = os.path.join(flashdir, "startup-config")
        if os.path.exists(dest):
            node.logger.info("Skipping copy of startup-config, already present")
        else:
            source = os.path.join(node.unet.config_dirname, startup_config)
            node.cmd_raises_nsonly(f"cp {source} {dest} && chmod 664 {dest}")

    #
    # system mac address (if not present already
    #
    dest = os.path.join(flashdir, "system_mac_address")
    if os.path.exists(dest):
        node.logger.info("Skipping system-mac generation, already present")
    else:
        random_arista_mac = "00:1c:73:%02x:%02x:%02x" % (
            random.randint(0, 255),
            random.randint(0, 255),
            random.randint(0, 255),
        )
        system_mac = node.config.get("system-mac", random_arista_mac)
        with open(dest, "w", encoding="ascii") as f:
            f.write(system_mac + "\n")
        node.cmd_raises_nsonly(f"chmod 664 {dest}")

    args = []

    # Pass special args for the environment variables
    if "env" in node.config:
        args += [f"systemd.setenv={k}={v}" for k, v in node.config["env"].items()]

    return cmds, [cmd] + args


# XXX this is only used by the container code
kind_run_cmd_update = {"ceos": run_cmd_update_ceos}
