#
# Rumba - nsenter executor (network + mount namespace per node)
#
#    Copyright (C) 2017-2026 imec
#
#    Dimitri Staessens <dimitri.staessens@ugent.be>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., http://www.fsf.org/about/contact/.
#

import os
import subprocess

from rumba import model as mod
from rumba.command import CommandResult

from rumba import log

logger = log.get_logger(__name__)


class NsenterExecutor(mod.Executor):
    """
    Executor that runs commands inside a node's network + mount namespace.

    Each node of the :class:`rumba.testbeds.nstb.Testbed` is a long-lived
    holder process (``unshare --net --mount ... sleep infinity``); its pid
    is recorded in ``testbed.holders[node.name]``.  Commands are wrapped in
    ``sudo nsenter -t <pid> --net --mount sh -c '<cmd>'`` so that:

    * privilege is acquired *outside* nsenter (entering a namespace needs
      CAP_SYS_ADMIN), never inside it;
    * a shell runs *inside* the namespace, preserving redirection and
      backgrounding (``nohup irmd > /dev/null &``, ``&&``, ...).
    """

    def __init__(self, testbed):
        self.testbed = testbed

    def _pid(self, node):
        """Return the live holder pid for node, or raise a clear error."""
        pid = self.testbed.holders.get(node.name)
        if pid is None:
            raise RuntimeError("No namespace holder for node %s" % node.name)
        if not os.path.exists('/proc/%d' % pid):
            raise RuntimeError(
                "Namespace holder for node %s (pid %d) is gone"
                % (node.name, pid))
        return pid

    def _nsenter(self, pid):
        return ['sudo', 'nsenter', '-t', str(pid), '--net', '--mount']

    def execute_command(self, node, command, as_root=False, time_out=3,
                        check=True):
        # We already enter the namespace as root via 'sudo nsenter', so
        # as_root needs no extra handling here.
        pid = self._pid(node)
        logger.debug("%s (pid %d) >> %s", node.name, pid, command)

        # Note: output is captured, so a command that backgrounds a long-lived
        # process (`foo &`) MUST redirect BOTH its stdout and stderr (e.g.
        # `> /dev/null 2>&1 &`); otherwise the inherited pipe stays open and
        # this call blocks until EOF.  stdin is /dev/null so a stray prompt
        # cannot wedge us.
        argv = self._nsenter(pid) + ['sh', '-c', command]
        try:
            proc = subprocess.run(argv, capture_output=True,
                                  stdin=subprocess.DEVNULL,
                                  universal_newlines=True, timeout=time_out)
        except subprocess.TimeoutExpired:
            logger.error("Command timed out after %ss: %s", time_out, command)
            raise

        result = CommandResult(
            exit_code=proc.returncode,
            stdout=proc.stdout.rstrip(),
            stderr=proc.stderr.rstrip(),
            command=command,
            node=node.name
        )

        if check:
            result.check()

        return result

    def fetch_file(self, node, path, destination, as_root=False):
        """Copy a file out of the node's (private) mount namespace."""
        pid = self._pid(node)
        if os.path.isdir(destination):
            destination = os.path.join(destination, os.path.basename(path))
        argv = self._nsenter(pid) + ['cat', path]
        proc = subprocess.run(argv, stdout=subprocess.PIPE,
                              stderr=subprocess.PIPE)
        if proc.returncode != 0:
            logger.error("Could not fetch %s from node %s: %s",
                         path, node.name, proc.stderr.decode(errors='replace'))
            return
        with open(destination, 'wb') as f:
            f.write(proc.stdout)

    def copy_file(self, node, path, destination):
        """Copy a local file into the node's (private) mount namespace."""
        pid = self._pid(node)
        with open(path, 'rb') as f:
            data = f.read()
        argv = self._nsenter(pid) + ['sh', '-c', 'cat > %s' % destination]
        subprocess.run(argv, input=data, stdout=subprocess.PIPE,
                       stderr=subprocess.PIPE)
