# -*- coding: utf-8 eval: (blacken-mode 1) -*-
#
# July 9 2021, Christian Hopps <chopps@labn.net>
#
# Copyright (c) 2021, LabN Consulting, L.L.C.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; see the file COPYING; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
import datetime
import logging
import os
import re
import shlex
import subprocess
import sys
import tempfile
import time as time_mod
import traceback

root_hostname = subprocess.check_output("hostname")

# This allows us to cleanup any leftovers later on
os.environ["MICRONET_PID"] = str(os.getpid())


class Timeout(object):
    def __init__(self, delta):
        self.started_on = datetime.datetime.now()
        self.expires_on = self.started_on + datetime.timedelta(seconds=delta)

    def elapsed(self):
        elapsed = datetime.datetime.now() - self.started_on
        return elapsed.total_seconds()

    def is_expired(self):
        return datetime.datetime.now() > self.expires_on


def is_string(value):
    """Return True if value is a string."""
    try:
        return isinstance(value, basestring)  # type: ignore
    except NameError:
        return isinstance(value, str)


def shell_quote(command):
    """Return command wrapped in single quotes."""
    if sys.version_info[0] >= 3:
        return shlex.quote(command)
    return "'{}'".format(command.replace("'", "'\"'\"'"))  # type: ignore


def cmd_error(rc, o, e):
    s = "rc {}".format(rc)
    o = "\n\tstdout: " + o.strip() if o and o.strip() else ""
    e = "\n\tstderr: " + e.strip() if e and e.strip() else ""
    return s + o + e


def proc_error(p, o, e):
    args = p.args if is_string(p.args) else " ".join(p.args)
    s = "rc {} pid {}\n\targs: {}".format(p.returncode, p.pid, args)
    o = "\n\tstdout: " + o.strip() if o and o.strip() else ""
    e = "\n\tstderr: " + e.strip() if e and e.strip() else ""
    return s + o + e


def comm_error(p):
    rc = p.poll()
    assert rc is not None
    if not hasattr(p, "saved_output"):
        p.saved_output = p.communicate()
    return proc_error(p, *p.saved_output)


class Commander(object):  # pylint: disable=R0205
    """
    Commander.

    An object that can execute commands.
    """

    tmux_wait_gen = 0

    def __init__(self, name, logger=None):
        """Create a Commander."""
        self.name = name
        self.last = None
        self.exec_paths = {}
        self.pre_cmd = []
        self.pre_cmd_str = ""

        if not logger:
            self.logger = logging.getLogger(__name__ + ".commander." + name)
        else:
            self.logger = logger

        self.cwd = self.cmd_raises("pwd").strip()

    def set_logger(self, logfile):
        self.logger = logging.getLogger(__name__ + ".commander." + self.name)
        if is_string(logfile):
            handler = logging.FileHandler(logfile, mode="w")
        else:
            handler = logging.StreamHandler(logfile)

        fmtstr = "%(asctime)s.%(msecs)03d %(levelname)s: {}({}): %(message)s".format(
            self.__class__.__name__, self.name
        )
        handler.setFormatter(logging.Formatter(fmt=fmtstr))
        self.logger.addHandler(handler)

    def set_pre_cmd(self, pre_cmd=None):
        if not pre_cmd:
            self.pre_cmd = []
            self.pre_cmd_str = ""
        else:
            self.pre_cmd = pre_cmd
            self.pre_cmd_str = " ".join(self.pre_cmd) + " "

    def __str__(self):
        return "Commander({})".format(self.name)

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

        `binary` :: binary name or list of binary names
        """
        if is_string(binary):
            bins = [binary]
        else:
            bins = binary
        for b in bins:
            if b in self.exec_paths:
                return self.exec_paths[b]

            rc, output, _ = self.cmd_status("which " + b, warn=False)
            if not rc:
                return os.path.abspath(output.strip())
            return None

    def get_tmp_dir(self, uniq):
        return os.path.join(tempfile.mkdtemp(), uniq)

    def test(self, flags, arg):
        """Run test binary, with flags and arg"""
        test_path = self.get_exec_path(["test"])
        rc, output, _ = self.cmd_status([test_path, flags, arg], warn=False)
        return not rc

    def path_exists(self, path):
        """Check if path exists."""
        return self.test("-e", path)

    def _get_cmd_str(self, cmd):
        if is_string(cmd):
            return self.pre_cmd_str + cmd
        cmd = self.pre_cmd + cmd
        return " ".join(cmd)

    def _get_sub_args(self, cmd, defaults, **kwargs):
        if is_string(cmd):
            defaults["shell"] = True
            pre_cmd = self.pre_cmd_str
        else:
            defaults["shell"] = False
            pre_cmd = self.pre_cmd
            cmd = [str(x) for x in cmd]
        defaults.update(kwargs)
        return pre_cmd, cmd, defaults

    def _popen(self, method, cmd, skip_pre_cmd=False, **kwargs):
        if sys.version_info[0] >= 3:
            defaults = {
                "encoding": "utf-8",
                "stdout": subprocess.PIPE,
                "stderr": subprocess.PIPE,
            }
        else:
            defaults = {
                "stdout": subprocess.PIPE,
                "stderr": subprocess.PIPE,
            }
        pre_cmd, cmd, defaults = self._get_sub_args(cmd, defaults, **kwargs)

        self.logger.debug('%s: %s("%s", kwargs: %s)', self, method, cmd, defaults)

        actual_cmd = cmd if skip_pre_cmd else pre_cmd + cmd
        p = subprocess.Popen(actual_cmd, **defaults)
        if not hasattr(p, "args"):
            p.args = actual_cmd
        return p, actual_cmd

    def set_cwd(self, cwd):
        self.logger.warning("%s: 'cd' (%s) does not work outside namespaces", self, cwd)
        self.cwd = cwd

    def popen(self, cmd, **kwargs):
        """
        Creates a pipe with the given `command`.

        Args:
            command: `str` or `list` of command to open a pipe with.
            **kwargs: kwargs is eventually passed on to Popen. If `command` is a string
                then will be invoked with shell=True, otherwise `command` is a list and
                will be invoked with shell=False.

        Returns:
            a subprocess.Popen object.
        """
        p, _ = self._popen("popen", cmd, **kwargs)
        return p

    def cmd_status(self, cmd, raises=False, warn=True, stdin=None, **kwargs):
        """Execute a command."""

        # We are not a shell like mininet, so we need to intercept this
        chdir = False
        if not is_string(cmd):
            cmds = cmd
        else:
            # XXX we can drop this when the code stops assuming it works
            m = re.match(r"cd(\s*|\s+(\S+))$", cmd)
            if m and m.group(2):
                self.logger.warning(
                    "Bad call to 'cd' (chdir) emulating, use self.set_cwd():\n%s",
                    "".join(traceback.format_stack(limit=12)),
                )
                assert is_string(cmd)
                chdir = True
                cmd += " && pwd"

            # If we are going to run under bash then we don't need shell=True!
            cmds = ["/bin/bash", "-c", cmd]

        pinput = None

        if is_string(stdin) or isinstance(stdin, bytes):
            pinput = stdin
            stdin = subprocess.PIPE

        p, actual_cmd = self._popen("cmd_status", cmds, stdin=stdin, **kwargs)
        stdout, stderr = p.communicate(input=pinput)
        rc = p.wait()

        # For debugging purposes.
        self.last = (rc, actual_cmd, cmd, stdout, stderr)

        if rc:
            if warn:
                self.logger.warning(
                    "%s: proc failed: %s:", self, proc_error(p, stdout, stderr)
                )
            if raises:
                # error = Exception("stderr: {}".format(stderr))
                # This annoyingly doesnt' show stderr when printed normally
                error = subprocess.CalledProcessError(rc, actual_cmd)
                error.stdout, error.stderr = stdout, stderr
                raise error
        elif chdir:
            self.set_cwd(stdout.strip())

        return rc, stdout, stderr

    def cmd_legacy(self, cmd, **kwargs):
        """Execute a command with stdout and stderr joined, *IGNORES ERROR*."""

        defaults = {"stderr": subprocess.STDOUT}
        defaults.update(kwargs)
        _, stdout, _ = self.cmd_status(cmd, raises=False, **defaults)
        return stdout

    def cmd_raises(self, cmd, **kwargs):
        """Execute a command. Raise an exception on errors"""

        rc, stdout, _ = self.cmd_status(cmd, raises=True, **kwargs)
        assert rc == 0
        return stdout

    # Run a command in a new window (gnome-terminal, screen, tmux, xterm)
    def run_in_window(
        self,
        cmd,
        wait_for=False,
        background=False,
        name=None,
        title=None,
        forcex=False,
        new_window=False,
        tmux_target=None,
    ):
        """
        Run a command in a new window (TMUX, Screen or XTerm).

        Args:
            wait_for: True to wait for exit from command or `str` as channel neme to signal on exit, otherwise False
            background: Do not change focus to new window.
            title: Title for new pane (tmux) or window (xterm).
            name: Name of the new window (tmux)
            forcex: Force use of X11.
            new_window: Open new window (instead of pane) in TMUX
            tmux_target: Target for tmux pane.

        Returns:
            the pane/window identifier from TMUX (depends on `new_window`)
        """

        channel = None
        if is_string(wait_for):
            channel = wait_for
        elif wait_for is True:
            channel = "{}-wait-{}".format(os.getpid(), Commander.tmux_wait_gen)
            Commander.tmux_wait_gen += 1

        sudo_path = self.get_exec_path(["sudo"])
        nscmd = sudo_path + " " + self.pre_cmd_str + cmd
        if "TMUX" in os.environ and not forcex:
            cmd = [self.get_exec_path("tmux")]
            if new_window:
                cmd.append("new-window")
                cmd.append("-P")
                if name:
                    cmd.append("-n")
                    cmd.append(name)
                if tmux_target:
                    cmd.append("-t")
                    cmd.append(tmux_target)
            else:
                cmd.append("split-window")
                cmd.append("-P")
                cmd.append("-h")
                if not tmux_target:
                    tmux_target = os.getenv("TMUX_PANE", "")
            if background:
                cmd.append("-d")
            if tmux_target:
                cmd.append("-t")
                cmd.append(tmux_target)
            if title:
                nscmd = "printf '\033]2;{}\033\\'; {}".format(title, nscmd)
            if channel:
                nscmd = 'trap "tmux wait -S {}; exit 0" EXIT; {}'.format(channel, nscmd)
            cmd.append(nscmd)
        elif "STY" in os.environ and not forcex:
            # wait for not supported in screen for now
            channel = None
            cmd = [self.get_exec_path("screen")]
            if not os.path.exists(
                "/run/screen/S-{}/{}".format(os.environ["USER"], os.environ["STY"])
            ):
                cmd = ["sudo", "-u", os.environ["SUDO_USER"]] + cmd
            cmd.append(nscmd)
        elif "DISPLAY" in os.environ:
            # We need it broken up for xterm
            user_cmd = cmd
            cmd = [self.get_exec_path("xterm")]
            if "SUDO_USER" in os.environ:
                cmd = [self.get_exec_path("sudo"), "-u", os.environ["SUDO_USER"]] + cmd
            if title:
                cmd.append("-T")
                cmd.append(title)
            cmd.append("-e")
            cmd.append(sudo_path)
            cmd.extend(self.pre_cmd)
            cmd.extend(["bash", "-c", user_cmd])
            # if channel:
            #    return self.cmd_raises(cmd, skip_pre_cmd=True)
            # else:
            p = self.popen(
                cmd,
                skip_pre_cmd=True,
                stdin=None,
                shell=False,
            )
            time_mod.sleep(2)
            if p.poll() is not None:
                self.logger.error("%s: Failed to launch xterm: %s", self, comm_error(p))
            return p
        else:
            self.logger.error(
                "DISPLAY, STY, and TMUX not in environment, can't open window"
            )
            raise Exception("Window requestd but TMUX, Screen and X11 not available")

        pane_info = self.cmd_raises(cmd, skip_pre_cmd=True).strip()

        # Re-adjust the layout
        if "TMUX" in os.environ:
            self.cmd_status(
                "tmux select-layout -t {} tiled".format(
                    pane_info if not tmux_target else tmux_target
                ),
                skip_pre_cmd=True,
            )

        # Wait here if we weren't handed the channel to wait for
        if channel and wait_for is True:
            cmd = [self.get_exec_path("tmux"), "wait", channel]
            self.cmd_status(cmd, skip_pre_cmd=True)

        return pane_info

    def delete(self):
        pass


class LinuxNamespace(Commander):
    """
    A linux Namespace.

    An object that creates and executes commands in a linux namespace
    """

    def __init__(
        self,
        name,
        net=True,
        mount=True,
        uts=True,
        cgroup=False,
        ipc=False,
        pid=False,
        time=False,
        user=False,
        set_hostname=True,
        private_mounts=None,
        logger=None,
    ):
        """
        Create a new linux namespace.

        Args:
            name: Internal name for the namespace.
            net: Create network namespace.
            mount: Create network namespace.
            uts: Create UTS (hostname) namespace.
            cgroup: Create cgroup namespace.
            ipc: Create IPC namespace.
            pid: Create PID namespace, also mounts new /proc.
            time: Create time namespace.
            user: Create user namespace, also keeps capabilities.
                set_hostname: Set the hostname to `name`, uts must also be True.
                private_mounts: List of strings of the form
                "[/external/path:]/internal/path. If no external path is specified a
                tmpfs is mounted on the internal path. Any paths specified are first
                passed to `mkdir -p`.
            logger: Passed to superclass.
        """
        super(LinuxNamespace, self).__init__(name, logger)

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

        self.intfs = []

        nslist = []
        cmd = ["/usr/bin/unshare"]
        flags = "-"
        self.ifnetns = {}

        if cgroup:
            nslist.append("cgroup")
            flags += "C"
        if ipc:
            nslist.append("ipc")
            flags += "i"
        if mount:
            nslist.append("mnt")
            flags += "m"
        if net:
            nslist.append("net")
            flags += "n"
        if pid:
            nslist.append("pid")
            flags += "p"
            cmd.append("--mount-proc")
        if time:
            # XXX this filename is probably wrong
            nslist.append("time")
            flags += "T"
        if user:
            nslist.append("user")
            flags += "U"
            cmd.append("--keep-caps")
        if uts:
            nslist.append("uts")
            cmd.append("--uts")

        cmd.append(flags)
        cmd.append("/bin/cat")

        # Using cat and a stdin PIPE is nice as it will exit when we do. However, we
        # also detach it from the pgid so that signals do not propagate to it. This is
        # b/c it would exit early (e.g., ^C) then, at least the main micronet proc which
        # has no other processes like frr daemons running, will take the main network
        # namespace with it, which will remove the bridges and the veth pair (because
        # the bridge side veth is deleted).
        self.logger.debug("%s: creating namespace process: %s", self, cmd)
        p = subprocess.Popen(
            cmd,
            stdin=subprocess.PIPE,
            stdout=open("/dev/null", "w"),
            stderr=open("/dev/null", "w"),
            preexec_fn=os.setsid,  # detach from pgid so signals don't propogate
            shell=False,
        )
        self.p = p
        self.pid = p.pid

        self.logger.debug("%s: namespace pid: %d", self, self.pid)

        # -----------------------------------------------
        # Now let's wait until unshare completes it's job
        # -----------------------------------------------
        timeout = Timeout(30)
        while p.poll() is None and not timeout.is_expired():
            for fname in tuple(nslist):
                ours = os.readlink("/proc/self/ns/{}".format(fname))
                theirs = os.readlink("/proc/{}/ns/{}".format(self.pid, fname))
                # See if their namespace is different
                if ours != theirs:
                    nslist.remove(fname)
            if not nslist:
                break
            elapsed = int(timeout.elapsed())
            if elapsed <= 3:
                time_mod.sleep(0.1)
            elif elapsed > 10:
                self.logger.warning("%s: unshare taking more than %ss", self, elapsed)
                time_mod.sleep(3)
            else:
                self.logger.info("%s: unshare taking more than %ss", self, elapsed)
                time_mod.sleep(1)
        assert p.poll() is None, "unshare unexpectedly exited!"
        assert not nslist, "unshare never unshared!"

        # Set pre-command based on our namespace proc
        self.base_pre_cmd = ["/usr/bin/nsenter", "-a", "-t", str(self.pid)]
        if not pid:
            self.base_pre_cmd.append("-F")
        self.set_pre_cmd(self.base_pre_cmd + ["--wd=" + self.cwd])

        # Remount /sys to pickup any changes
        self.cmd_raises("mount -t sysfs sysfs /sys")

        # Set the hostname to the namespace name
        if uts and set_hostname:
            # Debugging get the root hostname
            self.cmd_raises("hostname " + self.name)
            nroot = subprocess.check_output("hostname")
            if root_hostname != nroot:
                result = self.p.poll()
                assert root_hostname == nroot, "STATE of namespace process {}".format(
                    result
                )

        if private_mounts:
            if is_string(private_mounts):
                private_mounts = [private_mounts]
            for m in private_mounts:
                s = m.split(":", 1)
                if len(s) == 1:
                    self.tmpfs_mount(s[0])
                else:
                    self.bind_mount(s[0], s[1])

        o = self.cmd_legacy("ls -l /proc/{}/ns".format(self.pid))
        self.logger.debug("namespaces:\n %s", o)

        # Doing this here messes up all_protocols ipv6 check
        self.cmd_raises("ip link set lo up")

    def __str__(self):
        return "LinuxNamespace({})".format(self.name)

    def tmpfs_mount(self, inner):
        self.cmd_raises("mkdir -p " + inner)
        self.cmd_raises("mount -n -t tmpfs tmpfs " + inner)

    def bind_mount(self, outer, inner):
        self.cmd_raises("mkdir -p " + inner)
        self.cmd_raises("mount --rbind {} {} ".format(outer, inner))

    def add_netns(self, ns):
        self.logger.debug("Adding network namespace %s", ns)

        ip_path = self.get_exec_path("ip")
        assert ip_path, "XXX missing ip command!"
        if os.path.exists("/run/netns/{}".format(ns)):
            self.logger.warning("%s: Removing existing nsspace %s", self, ns)
            try:
                self.delete_netns(ns)
            except Exception as ex:
                self.logger.warning(
                    "%s: Couldn't remove existing nsspace %s: %s",
                    self,
                    ns,
                    str(ex),
                    exc_info=True,
                )
        self.cmd_raises([ip_path, "netns", "add", ns])

    def delete_netns(self, ns):
        self.logger.debug("Deleting network namespace %s", ns)

        ip_path = self.get_exec_path("ip")
        assert ip_path, "XXX missing ip command!"
        self.cmd_raises([ip_path, "netns", "delete", ns])

    def set_intf_netns(self, intf, ns, up=False):
        # In case a user hard-codes 1 thinking it "resets"
        ns = str(ns)
        if ns == "1":
            ns = str(self.pid)

        self.logger.debug("Moving interface %s to namespace %s", intf, ns)

        cmd = "ip link set {} netns " + ns
        if up:
            cmd += " up"
        self.intf_ip_cmd(intf, cmd)
        if ns == str(self.pid):
            # If we are returning then remove from dict
            if intf in self.ifnetns:
                del self.ifnetns[intf]
        else:
            self.ifnetns[intf] = ns

    def reset_intf_netns(self, intf):
        self.logger.debug("Moving interface %s to default namespace", intf)
        self.set_intf_netns(intf, str(self.pid))

    def intf_ip_cmd(self, intf, cmd):
        """Run an ip command for considering an interfaces possible namespace.

        `cmd` - format is run using the interface name on the command
        """
        if intf in self.ifnetns:
            assert cmd.startswith("ip ")
            cmd = "ip -n " + self.ifnetns[intf] + cmd[2:]
        self.cmd_raises(cmd.format(intf))

    def set_cwd(self, cwd):
        # Set pre-command based on our namespace proc
        self.logger.debug("%s: new CWD %s", self, cwd)
        self.set_pre_cmd(self.base_pre_cmd + ["--wd=" + cwd])

    def register_interface(self, ifname):
        if ifname not in self.intfs:
            self.intfs.append(ifname)

    def delete(self):
        if self.p and self.p.poll() is None:
            if sys.version_info[0] >= 3:
                try:
                    self.p.terminate()
                    self.p.communicate(timeout=10)
                except subprocess.TimeoutExpired:
                    self.p.kill()
                    self.p.communicate(timeout=2)
            else:
                self.p.kill()
                self.p.communicate()
        self.set_pre_cmd(["/bin/false"])


class SharedNamespace(Commander):
    """
    Share another namespace.

    An object that executes commands in an existing pid's linux namespace
    """

    def __init__(self, name, pid, logger=None):
        """
        Share a linux namespace.

        Args:
            name: Internal name for the namespace.
            pid: PID of the process to share with.
        """
        super(SharedNamespace, self).__init__(name, logger)

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

        self.pid = pid
        self.intfs = []

        # Set pre-command based on our namespace proc
        self.set_pre_cmd(
            ["/usr/bin/nsenter", "-a", "-t", str(self.pid), "--wd=" + self.cwd]
        )

    def __str__(self):
        return "SharedNamespace({})".format(self.name)

    def set_cwd(self, cwd):
        # Set pre-command based on our namespace proc
        self.logger.debug("%s: new CWD %s", self, cwd)
        self.set_pre_cmd(["/usr/bin/nsenter", "-a", "-t", str(self.pid), "--wd=" + cwd])

    def register_interface(self, ifname):
        if ifname not in self.intfs:
            self.intfs.append(ifname)


class Bridge(SharedNamespace):
    """
    A linux bridge.
    """

    next_brid_ord = 0

    @classmethod
    def _get_next_brid(cls):
        brid_ord = cls.next_brid_ord
        cls.next_brid_ord += 1
        return brid_ord

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

        self.unet = unet
        self.brid_ord = self._get_next_brid()
        if name:
            self.brid = name
        else:
            self.brid = "br{}".format(self.brid_ord)
            name = self.brid

        super(Bridge, self).__init__(name, unet.pid, logger)

        self.logger.debug("Bridge: Creating")

        assert len(self.brid) <= 16  # Make sure fits in IFNAMSIZE
        self.cmd_raises("ip link delete {} || true".format(self.brid))
        self.cmd_raises("ip link add {} type bridge".format(self.brid))
        self.cmd_raises("ip link set {} up".format(self.brid))

        self.logger.debug("%s: Created, Running", self)

    def __str__(self):
        return "Bridge({})".format(self.brid)

    def delete(self):
        """Stop the bridge (i.e., delete the linux resources)."""

        rc, o, e = self.cmd_status("ip link show {}".format(self.brid), warn=False)
        if not rc:
            rc, o, e = self.cmd_status(
                "ip link delete {}".format(self.brid), warn=False
            )
        if rc:
            self.logger.error(
                "%s: error deleting bridge %s: %s",
                self,
                self.brid,
                cmd_error(rc, o, e),
            )
        else:
            self.logger.debug("%s: Deleted.", self)


class Micronet(LinuxNamespace):  # pylint: disable=R0205
    """
    Micronet.
    """

    def __init__(self):
        """Create a Micronet."""

        self.hosts = {}
        self.switches = {}
        self.links = {}
        self.macs = {}
        self.rmacs = {}

        super(Micronet, self).__init__("micronet", mount=True, net=True, uts=True)

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

    def __str__(self):
        return "Micronet()"

    def __getitem__(self, key):
        if key in self.switches:
            return self.switches[key]
        return self.hosts[key]

    def add_host(self, name, cls=LinuxNamespace, **kwargs):
        """Add a host to micronet."""

        self.logger.debug("%s: add_host %s", self, name)

        self.hosts[name] = cls(name, **kwargs)
        # Create a new mounted FS for tracking nested network namespaces creatd by the
        # user with `ip netns add`
        self.hosts[name].tmpfs_mount("/run/netns")

    def add_link(self, name1, name2, if1, if2):
        """Add a link between switch and host to micronet."""
        isp2p = False
        if name1 in self.switches:
            assert name2 in self.hosts
        elif name2 in self.switches:
            assert name1 in self.hosts
            name1, name2 = name2, name1
            if1, if2 = if2, if1
        else:
            # p2p link
            assert name1 in self.hosts
            assert name2 in self.hosts
            isp2p = True

        lname = "{}:{}-{}:{}".format(name1, if1, name2, if2)
        self.logger.debug("%s: add_link %s%s", self, lname, " p2p" if isp2p else "")
        self.links[lname] = (name1, if1, name2, if2)

        # And create the veth now.
        if isp2p:
            lhost, rhost = self.hosts[name1], self.hosts[name2]
            lifname = "i1{:x}".format(lhost.pid)
            rifname = "i2{:x}".format(rhost.pid)
            self.cmd_raises(
                "ip link add {} type veth peer name {}".format(lifname, rifname)
            )

            self.cmd_raises("ip link set {} netns {}".format(lifname, lhost.pid))
            lhost.cmd_raises("ip link set {} name {}".format(lifname, if1))
            lhost.cmd_raises("ip link set {} up".format(if1))
            lhost.register_interface(if1)

            self.cmd_raises("ip link set {} netns {}".format(rifname, rhost.pid))
            rhost.cmd_raises("ip link set {} name {}".format(rifname, if2))
            rhost.cmd_raises("ip link set {} up".format(if2))
            rhost.register_interface(if2)
        else:
            switch = self.switches[name1]
            host = self.hosts[name2]

            assert len(if1) <= 16 and len(if2) <= 16  # Make sure fits in IFNAMSIZE

            self.logger.debug("%s: Creating veth pair for link %s", self, lname)
            self.cmd_raises(
                "ip link add {} type veth peer name {} netns {}".format(
                    if1, if2, host.pid
                )
            )
            self.cmd_raises("ip link set {} netns {}".format(if1, switch.pid))
            switch.register_interface(if1)
            host.register_interface(if2)
            self.cmd_raises("ip link set {} master {}".format(if1, switch.brid))
            self.cmd_raises("ip link set {} up".format(if1))
            host.cmd_raises("ip link set {} up".format(if2))

        # Cache the MAC values, and reverse mapping
        self.get_mac(name1, if1)
        self.get_mac(name2, if2)

    def add_switch(self, name):
        """Add a switch to micronet."""

        self.logger.debug("%s: add_switch %s", self, name)
        self.switches[name] = Bridge(name, self)

    def get_mac(self, name, ifname):
        if name in self.hosts:
            dev = self.hosts[name]
        else:
            dev = self.switches[name]

        if (name, ifname) not in self.macs:
            _, output, _ = dev.cmd_status("ip -o link show " + ifname)
            m = re.match(".*link/(loopback|ether) ([0-9a-fA-F:]+) .*", output)
            mac = m.group(2)
            self.macs[(name, ifname)] = mac
            self.rmacs[mac] = (name, ifname)

        return self.macs[(name, ifname)]

    def delete(self):
        """Delete the micronet topology."""

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

        for lname, (_, _, rname, rif) in self.links.items():
            host = self.hosts[rname]

            self.logger.debug("%s: Deleting veth pair for link %s", self, lname)

            rc, o, e = host.cmd_status("ip link delete {}".format(rif), warn=False)
            if rc:
                self.logger.error(
                    "Error deleting veth pair %s: %s", lname, cmd_error(rc, o, e)
                )

        self.links = {}

        for host in self.hosts.values():
            try:
                host.delete()
            except Exception as error:
                self.logger.error(
                    "%s: error while deleting host %s: %s", self, host, error
                )

        self.hosts = {}

        for switch in self.switches.values():
            try:
                switch.delete()
            except Exception as error:
                self.logger.error(
                    "%s: error while deleting switch %s: %s", self, switch, error
                )
        self.switches = {}

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

        super(Micronet, self).delete()


# ---------------------------
# Root level utility function
# ---------------------------


def get_exec_path(binary):
    base = Commander("base")
    return base.get_exec_path(binary)


commander = Commander("micronet")
