]> git.proxmox.com Git - mirror_frr.git/blob - tests/topotests/munet/base.py
c6ae70e09b21fe5a46efc504e07208c3c491d6b4
[mirror_frr.git] / tests / topotests / munet / base.py
1 # -*- coding: utf-8 eval: (blacken-mode 1) -*-
2 # SPDX-License-Identifier: GPL-2.0-or-later
3 #
4 # July 9 2021, Christian Hopps <chopps@labn.net>
5 #
6 # Copyright 2021, LabN Consulting, L.L.C.
7 #
8 """A module that implements core functionality for library or standalone use."""
9 import asyncio
10 import datetime
11 import errno
12 import ipaddress
13 import logging
14 import os
15 import platform
16 import re
17 import readline
18 import shlex
19 import signal
20 import subprocess
21 import sys
22 import tempfile
23 import time as time_mod
24
25 from collections import defaultdict
26 from pathlib import Path
27 from typing import Union
28
29 from . import config as munet_config
30 from . import linux
31
32
33 try:
34 import pexpect
35
36 from pexpect.fdpexpect import fdspawn
37 from pexpect.popen_spawn import PopenSpawn
38
39 have_pexpect = True
40 except ImportError:
41 have_pexpect = False
42
43 PEXPECT_PROMPT = "PEXPECT_PROMPT>"
44 PEXPECT_CONTINUATION_PROMPT = "PEXPECT_PROMPT+"
45
46 root_hostname = subprocess.check_output("hostname")
47 our_pid = os.getpid()
48
49
50 class MunetError(Exception):
51 """A generic munet error."""
52
53
54 class CalledProcessError(subprocess.CalledProcessError):
55 """Improved logging subclass of subprocess.CalledProcessError."""
56
57 def __str__(self):
58 o = self.output.strip() if self.output else ""
59 e = self.stderr.strip() if self.stderr else ""
60 s = f"returncode: {self.returncode} command: {self.cmd}"
61 o = "\n\tstdout: " + o if o else ""
62 e = "\n\tstderr: " + e if e else ""
63 return s + o + e
64
65 def __repr__(self):
66 o = self.output.strip() if self.output else ""
67 e = self.stderr.strip() if self.stderr else ""
68 return f"munet.base.CalledProcessError({self.returncode}, {self.cmd}, {o}, {e})"
69
70
71 class Timeout:
72 """An object to passively monitor for timeouts."""
73
74 def __init__(self, delta):
75 self.delta = datetime.timedelta(seconds=delta)
76 self.started_on = datetime.datetime.now()
77 self.expires_on = self.started_on + self.delta
78
79 def elapsed(self):
80 elapsed = datetime.datetime.now() - self.started_on
81 return elapsed.total_seconds()
82
83 def is_expired(self):
84 return datetime.datetime.now() > self.expires_on
85
86 def remaining(self):
87 remaining = self.expires_on - datetime.datetime.now()
88 return remaining.total_seconds()
89
90 def __iter__(self):
91 return self
92
93 def __next__(self):
94 remaining = self.remaining()
95 if remaining <= 0:
96 raise StopIteration()
97 return remaining
98
99
100 def fsafe_name(name):
101 return "".join(x if x.isalnum() else "_" for x in name)
102
103
104 def indent(s):
105 return "\t" + s.replace("\n", "\n\t")
106
107
108 def shell_quote(command):
109 """Return command wrapped in single quotes."""
110 if sys.version_info[0] >= 3:
111 return shlex.quote(command)
112 return "'" + command.replace("'", "'\"'\"'") + "'"
113
114
115 def cmd_error(rc, o, e):
116 s = f"rc {rc}"
117 o = "\n\tstdout: " + o.strip() if o and o.strip() else ""
118 e = "\n\tstderr: " + e.strip() if e and e.strip() else ""
119 return s + o + e
120
121
122 def proc_str(p):
123 if hasattr(p, "args"):
124 args = p.args if isinstance(p.args, str) else " ".join(p.args)
125 else:
126 args = ""
127 return f"proc pid: {p.pid} args: {args}"
128
129
130 def proc_error(p, o, e):
131 if hasattr(p, "args"):
132 args = p.args if isinstance(p.args, str) else " ".join(p.args)
133 else:
134 args = ""
135
136 s = f"rc {p.returncode} pid {p.pid}"
137 a = "\n\targs: " + args if args else ""
138 o = "\n\tstdout: " + (o.strip() if o and o.strip() else "*empty*")
139 e = "\n\tstderr: " + (e.strip() if e and e.strip() else "*empty*")
140 return s + a + o + e
141
142
143 def comm_error(p):
144 rc = p.poll()
145 assert rc is not None
146 if not hasattr(p, "saved_output"):
147 p.saved_output = p.communicate()
148 return proc_error(p, *p.saved_output)
149
150
151 async def acomm_error(p):
152 rc = p.returncode
153 assert rc is not None
154 if not hasattr(p, "saved_output"):
155 p.saved_output = await p.communicate()
156 return proc_error(p, *p.saved_output)
157
158
159 def get_kernel_version():
160 kvs = (
161 subprocess.check_output("uname -r", shell=True, text=True).strip().split("-", 1)
162 )
163 kv = kvs[0].split(".")
164 kv = [int(x) for x in kv]
165 return kv
166
167
168 def convert_number(value) -> int:
169 """Convert a number value with a possible suffix to an integer.
170
171 >>> convert_number("100k") == 100 * 1024
172 True
173 >>> convert_number("100M") == 100 * 1000 * 1000
174 True
175 >>> convert_number("100Gi") == 100 * 1024 * 1024 * 1024
176 True
177 >>> convert_number("55") == 55
178 True
179 """
180 if value is None:
181 raise ValueError("Invalid value None for convert_number")
182 rate = str(value)
183 base = 1000
184 if rate[-1] == "i":
185 base = 1024
186 rate = rate[:-1]
187 suffix = "KMGTPEZY"
188 index = suffix.find(rate[-1])
189 if index == -1:
190 base = 1024
191 index = suffix.lower().find(rate[-1])
192 if index != -1:
193 rate = rate[:-1]
194 return int(rate) * base ** (index + 1)
195
196
197 def is_file_like(fo):
198 return isinstance(fo, int) or hasattr(fo, "fileno")
199
200
201 def get_tc_bits_value(user_value):
202 value = convert_number(user_value) / 1000
203 return f"{value:03f}kbit"
204
205
206 def get_tc_bytes_value(user_value):
207 # Raw numbers are bytes in tc
208 return convert_number(user_value)
209
210
211 def get_tmp_dir(uniq):
212 return os.path.join(tempfile.mkdtemp(), uniq)
213
214
215 async def _async_get_exec_path(binary, cmdf, cache):
216 if isinstance(binary, str):
217 bins = [binary]
218 else:
219 bins = binary
220 for b in bins:
221 if b in cache:
222 return cache[b]
223
224 rc, output, _ = await cmdf("which " + b, warn=False)
225 if not rc:
226 cache[b] = os.path.abspath(output.strip())
227 return cache[b]
228 return None
229
230
231 def _get_exec_path(binary, cmdf, cache):
232 if isinstance(binary, str):
233 bins = [binary]
234 else:
235 bins = binary
236 for b in bins:
237 if b in cache:
238 return cache[b]
239
240 rc, output, _ = cmdf("which " + b, warn=False)
241 if not rc:
242 cache[b] = os.path.abspath(output.strip())
243 return cache[b]
244 return None
245
246
247 def get_event_loop():
248 """Configure and return our non-thread using event loop.
249
250 This function configures a new child watcher to not use threads.
251 Threads cannot be used when we inline unshare a PID namespace.
252 """
253 policy = asyncio.get_event_loop_policy()
254 loop = policy.get_event_loop()
255 owatcher = policy.get_child_watcher()
256 logging.debug(
257 "event_loop_fixture: global policy %s, current loop %s, current watcher %s",
258 policy,
259 loop,
260 owatcher,
261 )
262
263 policy.set_child_watcher(None)
264 owatcher.close()
265
266 try:
267 watcher = asyncio.PidfdChildWatcher() # pylint: disable=no-member
268 except Exception:
269 watcher = asyncio.SafeChildWatcher()
270 loop = policy.get_event_loop()
271
272 logging.debug(
273 "event_loop_fixture: attaching new watcher %s to loop and setting in policy",
274 watcher,
275 )
276 watcher.attach_loop(loop)
277 policy.set_child_watcher(watcher)
278 policy.set_event_loop(loop)
279 assert asyncio.get_event_loop_policy().get_child_watcher() is watcher
280
281 return loop
282
283
284 class Commander: # pylint: disable=R0904
285 """An object that can execute commands."""
286
287 tmux_wait_gen = 0
288
289 def __init__(self, name, logger=None, unet=None, **kwargs):
290 """Create a Commander.
291
292 Args:
293 name: name of the commander object
294 logger: logger to use for logging commands a defualt is supplied if this
295 is None
296 unet: unet that owns this object, only used by Commander in run_in_window,
297 otherwise can be None.
298 """
299 # del kwargs # deal with lint warning
300 # logging.warning("Commander: name %s kwargs %s", name, kwargs)
301
302 self.name = name
303 self.unet = unet
304 self.deleting = False
305 self.last = None
306 self.exec_paths = {}
307
308 if not logger:
309 logname = f"munet.{self.__class__.__name__.lower()}.{name}"
310 self.logger = logging.getLogger(logname)
311 self.logger.setLevel(logging.DEBUG)
312 else:
313 self.logger = logger
314
315 super().__init__(**kwargs)
316
317 @property
318 def is_vm(self):
319 return False
320
321 @property
322 def is_container(self):
323 return False
324
325 def set_logger(self, logfile):
326 self.logger = logging.getLogger(__name__ + ".commander." + self.name)
327 self.logger.setLevel(logging.DEBUG)
328 if isinstance(logfile, str):
329 handler = logging.FileHandler(logfile, mode="w")
330 else:
331 handler = logging.StreamHandler(logfile)
332
333 fmtstr = "%(asctime)s.%(msecs)03d %(levelname)s: {}({}): %(message)s".format(
334 self.__class__.__name__, self.name
335 )
336 handler.setFormatter(logging.Formatter(fmt=fmtstr))
337 self.logger.addHandler(handler)
338
339 def _get_pre_cmd(self, use_str, use_pty, **kwargs):
340 """Get the pre-user-command values.
341
342 The values returned here should be what is required to cause the user's command
343 to execute in the correct context (e.g., namespace, container, sshremote).
344 """
345 del kwargs
346 del use_pty
347 return "" if use_str else []
348
349 def __str__(self):
350 return f"{self.__class__.__name__}({self.name})"
351
352 async def async_get_exec_path(self, binary):
353 """Return the full path to the binary executable.
354
355 `binary` :: binary name or list of binary names
356 """
357 return await _async_get_exec_path(
358 binary, self.async_cmd_status_nsonly, self.exec_paths
359 )
360
361 def get_exec_path(self, binary):
362 """Return the full path to the binary executable.
363
364 `binary` :: binary name or list of binary names
365 """
366 return _get_exec_path(binary, self.cmd_status_nsonly, self.exec_paths)
367
368 def get_exec_path_host(self, binary):
369 """Return the full path to the binary executable.
370
371 If the object is actually a derived class (e.g., a container) this method will
372 return the exec path for the native namespace rather than the container. The
373 path is the one which the other xxx_host methods will use.
374
375 `binary` :: binary name or list of binary names
376 """
377 return get_exec_path_host(binary)
378
379 def test(self, flags, arg):
380 """Run test binary, with flags and arg."""
381 test_path = self.get_exec_path(["test"])
382 rc, _, _ = self.cmd_status([test_path, flags, arg], warn=False)
383 return not rc
384
385 def test_nsonly(self, flags, arg):
386 """Run test binary, with flags and arg."""
387 test_path = self.get_exec_path(["test"])
388 rc, _, _ = self.cmd_status_nsonly([test_path, flags, arg], warn=False)
389 return not rc
390
391 def path_exists(self, path):
392 """Check if path exists."""
393 return self.test("-e", path)
394
395 async def cleanup_pid(self, pid, kill_pid=None):
396 """Signal a pid to exit with escalating forcefulness."""
397 if kill_pid is None:
398 kill_pid = pid
399
400 for sn in (signal.SIGHUP, signal.SIGKILL):
401 self.logger.debug(
402 "%s: %s %s (wait %s)", self, signal.Signals(sn).name, kill_pid, pid
403 )
404
405 os.kill(kill_pid, sn)
406
407 # No need to wait after this.
408 if sn == signal.SIGKILL:
409 return
410
411 # try each signal, waiting 15 seconds for exit before advancing
412 wait_sec = 30
413 self.logger.debug("%s: waiting %ss for pid to exit", self, wait_sec)
414 for _ in Timeout(wait_sec):
415 try:
416 status = os.waitpid(pid, os.WNOHANG)
417 if status == (0, 0):
418 await asyncio.sleep(0.1)
419 else:
420 self.logger.debug("pid %s exited status %s", pid, status)
421 return
422 except OSError as error:
423 if error.errno == errno.ECHILD:
424 self.logger.debug("%s: pid %s was reaped", self, pid)
425 else:
426 self.logger.warning(
427 "%s: error waiting on pid %s: %s", self, pid, error
428 )
429 return
430 self.logger.debug("%s: timeout waiting on pid %s to exit", self, pid)
431
432 def _get_sub_args(self, cmd_list, defaults, use_pty=False, ns_only=False, **kwargs):
433 """Returns pre-command, cmd, and default keyword args."""
434 assert not isinstance(cmd_list, str)
435
436 defaults["shell"] = False
437 pre_cmd_list = self._get_pre_cmd(False, use_pty, ns_only=ns_only, **kwargs)
438 cmd_list = [str(x) for x in cmd_list]
439
440 # os_env = {k: v for k, v in os.environ.items() if k.startswith("MUNET")}
441 # env = {**os_env, **(kwargs["env"] if "env" in kwargs else {})}
442 env = {**(kwargs["env"] if "env" in kwargs else os.environ)}
443 if "MUNET_NODENAME" not in env:
444 env["MUNET_NODENAME"] = self.name
445 kwargs["env"] = env
446
447 defaults.update(kwargs)
448
449 return pre_cmd_list, cmd_list, defaults
450
451 def _common_prologue(self, async_exec, method, cmd, skip_pre_cmd=False, **kwargs):
452 cmd_list = self._get_cmd_as_list(cmd)
453 if method == "_spawn":
454 defaults = {
455 "encoding": "utf-8",
456 "codec_errors": "ignore",
457 }
458 else:
459 defaults = {
460 "stdout": subprocess.PIPE,
461 "stderr": subprocess.PIPE,
462 }
463 if not async_exec:
464 defaults["encoding"] = "utf-8"
465
466 pre_cmd_list, cmd_list, defaults = self._get_sub_args(
467 cmd_list, defaults, **kwargs
468 )
469
470 use_pty = kwargs.get("use_pty", False)
471 if method == "_spawn":
472 # spawn doesn't take "shell" keyword arg
473 if "shell" in defaults:
474 del defaults["shell"]
475 # this is required to avoid receiving a STOPPED signal on expect!
476 if not use_pty:
477 defaults["preexec_fn"] = os.setsid
478 defaults["env"]["PS1"] = "$ "
479
480 self.logger.debug(
481 '%s: %s %s("%s", pre_cmd: "%s" use_pty: %s kwargs: %.120s)',
482 self,
483 "XXX" if method == "_spawn" else "",
484 method,
485 cmd_list,
486 pre_cmd_list if not skip_pre_cmd else "",
487 use_pty,
488 defaults,
489 )
490
491 actual_cmd_list = cmd_list if skip_pre_cmd else pre_cmd_list + cmd_list
492 return actual_cmd_list, defaults
493
494 async def _async_popen(self, method, cmd, **kwargs):
495 """Create a new asynchronous subprocess."""
496 acmd, kwargs = self._common_prologue(True, method, cmd, **kwargs)
497 p = await asyncio.create_subprocess_exec(*acmd, **kwargs)
498 return p, acmd
499
500 def _popen(self, method, cmd, **kwargs):
501 """Create a subprocess."""
502 acmd, kwargs = self._common_prologue(False, method, cmd, **kwargs)
503 p = subprocess.Popen(acmd, **kwargs)
504 return p, acmd
505
506 def _fdspawn(self, fo, **kwargs):
507 defaults = {}
508 defaults.update(kwargs)
509
510 if "echo" in defaults:
511 del defaults["echo"]
512
513 if "encoding" not in defaults:
514 defaults["encoding"] = "utf-8"
515 if "codec_errors" not in defaults:
516 defaults["codec_errors"] = "ignore"
517 encoding = defaults["encoding"]
518
519 self.logger.debug("%s: _fdspawn(%s, kwargs: %s)", self, fo, defaults)
520
521 p = fdspawn(fo, **defaults)
522
523 # We don't have TTY like conversions of LF to CRLF
524 p.crlf = os.linesep.encode(encoding)
525
526 # we own the socket now detach the file descriptor to keep it from closing
527 if hasattr(fo, "detach"):
528 fo.detach()
529
530 return p
531
532 def _spawn(self, cmd, skip_pre_cmd=False, use_pty=False, echo=False, **kwargs):
533 logging.debug(
534 '%s: XXX _spawn: cmd "%s" skip_pre_cmd %s use_pty %s echo %s kwargs %s',
535 self,
536 cmd,
537 skip_pre_cmd,
538 use_pty,
539 echo,
540 kwargs,
541 )
542 actual_cmd, defaults = self._common_prologue(
543 False, "_spawn", cmd, skip_pre_cmd=skip_pre_cmd, use_pty=use_pty, **kwargs
544 )
545
546 self.logger.debug(
547 '%s: XXX %s("%s", use_pty %s echo %s defaults: %s)',
548 self,
549 "PopenSpawn" if not use_pty else "pexpect.spawn",
550 actual_cmd,
551 use_pty,
552 echo,
553 defaults,
554 )
555
556 # We don't specify a timeout it defaults to 30s is that OK?
557 if not use_pty:
558 p = PopenSpawn(actual_cmd, **defaults)
559 else:
560 p = pexpect.spawn(actual_cmd[0], actual_cmd[1:], echo=echo, **defaults)
561 return p, actual_cmd
562
563 def spawn(
564 self,
565 cmd,
566 spawned_re,
567 expects=(),
568 sends=(),
569 use_pty=False,
570 logfile=None,
571 logfile_read=None,
572 logfile_send=None,
573 trace=None,
574 **kwargs,
575 ):
576 """Create a spawned send/expect process.
577
578 Args:
579 cmd: list of args to exec/popen with, or an already open socket
580 spawned_re: what to look for to know when done, `spawn` returns when seen
581 expects: a list of regex other than `spawned_re` to look for. Commonly,
582 "ogin:" or "[Pp]assword:"r.
583 sends: what to send when an element of `expects` matches. So e.g., the
584 username or password if thats what corresponding expect matched. Can
585 be the empty string to send nothing.
586 use_pty: true for pty based expect, otherwise uses popen (pipes/files)
587 trace: if true then log send/expects
588 **kwargs - kwargs passed on the _spawn.
589
590 Returns:
591 A pexpect process.
592
593 Raises:
594 pexpect.TIMEOUT, pexpect.EOF as documented in `pexpect`
595 CalledProcessError if EOF is seen and `cmd` exited then
596 raises a CalledProcessError to indicate the failure.
597 """
598 if is_file_like(cmd):
599 assert not use_pty
600 ac = "*socket*"
601 p = self._fdspawn(cmd, **kwargs)
602 else:
603 p, ac = self._spawn(cmd, use_pty=use_pty, **kwargs)
604
605 if logfile:
606 p.logfile = logfile
607 if logfile_read:
608 p.logfile_read = logfile_read
609 if logfile_send:
610 p.logfile_send = logfile_send
611
612 # for spawned shells (i.e., a direct command an not a console)
613 # this is wrong and will cause 2 prompts
614 if not use_pty:
615 # This isn't very nice looking
616 p.echo = False
617 if not is_file_like(cmd):
618 p.isalive = lambda: p.proc.poll() is None
619 if not hasattr(p, "close"):
620 p.close = p.wait
621
622 # Do a quick check to see if we got the prompt right away, otherwise we may be
623 # at a console so we send a \n to re-issue the prompt
624 index = p.expect([spawned_re, pexpect.TIMEOUT, pexpect.EOF], timeout=0.1)
625 if index == 0:
626 assert p.match is not None
627 self.logger.debug(
628 "%s: got spawned_re quick: '%s' matching '%s'",
629 self,
630 p.match.group(0),
631 spawned_re,
632 )
633 return p
634
635 # Now send a CRLF to cause the prompt (or whatever else) to re-issue
636 p.send("\n")
637 try:
638 patterns = [spawned_re, *expects]
639
640 self.logger.debug("%s: expecting: %s", self, patterns)
641
642 while index := p.expect(patterns):
643 if trace:
644 assert p.match is not None
645 self.logger.debug(
646 "%s: got expect: '%s' matching %d '%s', sending '%s'",
647 self,
648 p.match.group(0),
649 index,
650 patterns[index],
651 sends[index - 1],
652 )
653 if sends[index - 1]:
654 p.send(sends[index - 1])
655
656 self.logger.debug("%s: expecting again: %s", self, patterns)
657 self.logger.debug(
658 "%s: got spawned_re: '%s' matching '%s'",
659 self,
660 p.match.group(0),
661 spawned_re,
662 )
663 return p
664 except pexpect.TIMEOUT:
665 self.logger.error(
666 "%s: TIMEOUT looking for spawned_re '%s' expect buffer so far:\n%s",
667 self,
668 spawned_re,
669 indent(p.buffer),
670 )
671 raise
672 except pexpect.EOF as eoferr:
673 if p.isalive():
674 raise
675 rc = p.status
676 before = indent(p.before)
677 error = CalledProcessError(rc, ac, output=before)
678 self.logger.error(
679 "%s: EOF looking for spawned_re '%s' before EOF:\n%s",
680 self,
681 spawned_re,
682 before,
683 )
684 p.close()
685 raise error from eoferr
686
687 async def shell_spawn(
688 self,
689 cmd,
690 prompt,
691 expects=(),
692 sends=(),
693 use_pty=False,
694 will_echo=False,
695 is_bourne=True,
696 init_newline=False,
697 **kwargs,
698 ):
699 """Create a shell REPL (read-eval-print-loop).
700
701 Args:
702 cmd: shell and list of args to popen with, or an already open socket
703 prompt: the REPL prompt to look for, the function returns when seen
704 expects: a list of regex other than `spawned_re` to look for. Commonly,
705 "ogin:" or "[Pp]assword:"r.
706 sends: what to send when an element of `expects` matches. So e.g., the
707 username or password if thats what corresponding expect matched. Can
708 be the empty string to send nothing.
709 is_bourne: if False then do not modify shell prompt for internal
710 parser friently format, and do not expect continuation prompts.
711 init_newline: send an initial newline for non-bourne shell spawns, otherwise
712 expect the prompt simply from running the command
713 use_pty: true for pty based expect, otherwise uses popen (pipes/files)
714 will_echo: bash is buggy in that it echo's to non-tty unlike any other
715 sh/ksh, set this value to true if running back
716 **kwargs - kwargs passed on the _spawn.
717 """
718 combined_prompt = r"({}|{})".format(re.escape(PEXPECT_PROMPT), prompt)
719
720 assert not is_file_like(cmd) or not use_pty
721 p = self.spawn(
722 cmd,
723 combined_prompt,
724 expects=expects,
725 sends=sends,
726 use_pty=use_pty,
727 echo=False,
728 **kwargs,
729 )
730 assert not p.echo
731
732 if not is_bourne:
733 if init_newline:
734 p.send("\n")
735 return ShellWrapper(p, prompt, will_echo=will_echo)
736
737 ps1 = PEXPECT_PROMPT
738 ps2 = PEXPECT_CONTINUATION_PROMPT
739
740 # Avoid problems when =/usr/bin/env= prints the values
741 ps1p = ps1[:5] + "${UNSET_V}" + ps1[5:]
742 ps2p = ps2[:5] + "${UNSET_V}" + ps2[5:]
743
744 ps1 = re.escape(ps1)
745 ps2 = re.escape(ps2)
746
747 extra = "PAGER=cat; export PAGER; TERM=dumb; unset HISTFILE; set +o emacs +o vi"
748 pchg = "PS1='{0}' PS2='{1}' PROMPT_COMMAND=''\n".format(ps1p, ps2p)
749 p.send(pchg)
750 return ShellWrapper(p, ps1, ps2, extra_init_cmd=extra, will_echo=will_echo)
751
752 def popen(self, cmd, **kwargs):
753 """Creates a pipe with the given `command`.
754
755 Args:
756 cmd: `str` or `list` of command to open a pipe with.
757 **kwargs: kwargs is eventually passed on to Popen. If `command` is a string
758 then will be invoked with `bash -c`, otherwise `command` is a list and
759 will be invoked without a shell.
760
761 Returns:
762 a subprocess.Popen object.
763 """
764 return self._popen("popen", cmd, **kwargs)[0]
765
766 def popen_nsonly(self, cmd, **kwargs):
767 """Creates a pipe with the given `command`.
768
769 Args:
770 cmd: `str` or `list` of command to open a pipe with.
771 **kwargs: kwargs is eventually passed on to Popen. If `command` is a string
772 then will be invoked with `bash -c`, otherwise `command` is a list and
773 will be invoked without a shell.
774
775 Returns:
776 a subprocess.Popen object.
777 """
778 return self._popen("popen_nsonly", cmd, ns_only=True, **kwargs)[0]
779
780 async def async_popen(self, cmd, **kwargs):
781 """Creates a pipe with the given `command`.
782
783 Args:
784 cmd: `str` or `list` of command to open a pipe with.
785 **kwargs: kwargs is eventually passed on to create_subprocess_exec. If
786 `command` is a string then will be invoked with `bash -c`, otherwise
787 `command` is a list and will be invoked without a shell.
788
789 Returns:
790 a asyncio.subprocess.Process object.
791 """
792 p, _ = await self._async_popen("async_popen", cmd, **kwargs)
793 return p
794
795 async def async_popen_nsonly(self, cmd, **kwargs):
796 """Creates a pipe with the given `command`.
797
798 Args:
799 cmd: `str` or `list` of command to open a pipe with.
800 **kwargs: kwargs is eventually passed on to create_subprocess_exec. If
801 `command` is a string then will be invoked with `bash -c`, otherwise
802 `command` is a list and will be invoked without a shell.
803
804 Returns:
805 a asyncio.subprocess.Process object.
806 """
807 p, _ = await self._async_popen(
808 "async_popen_nsonly", cmd, ns_only=True, **kwargs
809 )
810 return p
811
812 async def async_cleanup_proc(self, p, pid=None):
813 """Terminate a process started with a popen call.
814
815 Args:
816 p: return value from :py:`async_popen`, :py:`popen`, et al.
817 pid: pid to signal instead of p.pid, typically a child of
818 cmd_p == nsenter.
819
820 Returns:
821 None on success, the ``p`` if multiple timeouts occur even
822 after a SIGKILL sent.
823 """
824 if not p:
825 return None
826
827 if p.returncode is not None:
828 if isinstance(p, subprocess.Popen):
829 o, e = p.communicate()
830 else:
831 o, e = await p.communicate()
832 self.logger.debug(
833 "%s: cmd_p already exited status: %s", self, proc_error(p, o, e)
834 )
835 return None
836
837 if pid is None:
838 pid = p.pid
839
840 self.logger.debug("%s: terminate process: %s (pid %s)", self, proc_str(p), pid)
841 try:
842 # This will SIGHUP and wait a while then SIGKILL and return immediately
843 await self.cleanup_pid(p.pid, pid)
844
845 # Wait another 2 seconds after the possible SIGKILL above for the
846 # parent nsenter to cleanup and exit
847 wait_secs = 2
848 if isinstance(p, subprocess.Popen):
849 o, e = p.communicate(timeout=wait_secs)
850 else:
851 o, e = await asyncio.wait_for(p.communicate(), timeout=wait_secs)
852 self.logger.debug(
853 "%s: cmd_p exited after kill, status: %s", self, proc_error(p, o, e)
854 )
855 except (asyncio.TimeoutError, subprocess.TimeoutExpired):
856 self.logger.warning("%s: SIGKILL timeout", self)
857 return p
858 except Exception as error:
859 self.logger.warning(
860 "%s: kill unexpected exception: %s", self, error, exc_info=True
861 )
862 return p
863 return None
864
865 @staticmethod
866 def _cmd_status_input(stdin):
867 pinput = None
868 if isinstance(stdin, (bytes, str)):
869 pinput = stdin
870 stdin = subprocess.PIPE
871 return pinput, stdin
872
873 def _cmd_status_finish(self, p, c, ac, o, e, raises, warn):
874 rc = p.returncode
875 self.last = (rc, ac, c, o, e)
876 if rc:
877 if warn:
878 self.logger.warning("%s: proc failed: %s", self, proc_error(p, o, e))
879 if raises:
880 # error = Exception("stderr: {}".format(stderr))
881 # This annoyingly doesnt' show stderr when printed normally
882 raise CalledProcessError(rc, ac, o, e)
883 return rc, o, e
884
885 def _cmd_status(self, cmds, raises=False, warn=True, stdin=None, **kwargs):
886 """Execute a command."""
887 pinput, stdin = Commander._cmd_status_input(stdin)
888 p, actual_cmd = self._popen("cmd_status", cmds, stdin=stdin, **kwargs)
889 o, e = p.communicate(pinput)
890 return self._cmd_status_finish(p, cmds, actual_cmd, o, e, raises, warn)
891
892 async def _async_cmd_status(
893 self, cmds, raises=False, warn=True, stdin=None, text=None, **kwargs
894 ):
895 """Execute a command."""
896 pinput, stdin = Commander._cmd_status_input(stdin)
897 p, actual_cmd = await self._async_popen(
898 "async_cmd_status", cmds, stdin=stdin, **kwargs
899 )
900
901 if text is False:
902 encoding = None
903 else:
904 encoding = kwargs.get("encoding", "utf-8")
905
906 if encoding is not None and isinstance(pinput, str):
907 pinput = pinput.encode(encoding)
908 o, e = await p.communicate(pinput)
909 if encoding is not None:
910 o = o.decode(encoding) if o is not None else o
911 e = e.decode(encoding) if e is not None else e
912 return self._cmd_status_finish(p, cmds, actual_cmd, o, e, raises, warn)
913
914 def _get_cmd_as_list(self, cmd):
915 """Given a list or string return a list form for execution.
916
917 If `cmd` is a string then the returned list uses bash and looks
918 like this: ["/bin/bash", "-c", cmd]. Some node types override
919 this function if they utilize a different shell as to return
920 a different list of values.
921
922 Args:
923 cmd: list or string representing the command to execute.
924
925 Returns:
926 list of commands to execute.
927 """
928 if not isinstance(cmd, str):
929 cmds = cmd
930 else:
931 # Make sure the code doesn't think `cd` will work.
932 assert not re.match(r"cd(\s*|\s+(\S+))$", cmd)
933 cmds = ["/bin/bash", "-c", cmd]
934 return cmds
935
936 def cmd_nostatus(self, cmd, **kwargs):
937 """Run given command returning output[s].
938
939 Args:
940 cmd: `str` or `list` of the command to execute. If a string is given
941 it is run using a shell, otherwise the list is executed directly
942 as the binary and arguments.
943 **kwargs: kwargs is eventually passed on to Popen. If `command` is a string
944 then will be invoked with `bash -c`, otherwise `command` is a list and
945 will be invoked without a shell.
946
947 Returns:
948 if "stderr" is in kwargs and not equal to subprocess.STDOUT, then
949 both stdout and stderr are returned, otherwise stderr is combined
950 with stdout and only stdout is returned.
951 """
952 #
953 # This method serves as the basis for all derived sync cmd variations, so to
954 # override sync cmd behavior simply override this function and *not* the other
955 # variations, unless you are changing only that variation's behavior
956 #
957
958 # XXX change this back to _cmd_status instead of cmd_status when we
959 # consolidate and cleanup the container overrides of *cmd_* functions
960
961 cmds = cmd
962 if "stderr" in kwargs and kwargs["stderr"] != subprocess.STDOUT:
963 _, o, e = self.cmd_status(cmds, **kwargs)
964 return o, e
965 if "stderr" in kwargs:
966 del kwargs["stderr"]
967 _, o, _ = self.cmd_status(cmds, stderr=subprocess.STDOUT, **kwargs)
968 return o
969
970 def cmd_status(self, cmd, **kwargs):
971 """Run given command returning status and outputs.
972
973 Args:
974 cmd: `str` or `list` of the command to execute. If a string is given
975 it is run using a shell, otherwise the list is executed directly
976 as the binary and arguments.
977 **kwargs: kwargs is eventually passed on to Popen. If `command` is a string
978 then will be invoked with `bash -c`, otherwise `command` is a list and
979 will be invoked without a shell.
980
981 Returns:
982 (status, output, error) are returned
983 status: the returncode of the command.
984 output: stdout as a string from the command.
985 error: stderr as a string from the command.
986 """
987 #
988 # This method serves as the basis for all derived sync cmd variations, so to
989 # override sync cmd behavior simply override this function and *not* the other
990 # variations, unless you are changing only that variation's behavior
991 #
992 return self._cmd_status(cmd, **kwargs)
993
994 def cmd_raises(self, cmd, **kwargs):
995 """Execute a command. Raise an exception on errors.
996
997 Args:
998 cmd: `str` or `list` of the command to execute. If a string is given
999 it is run using a shell, otherwise the list is executed directly
1000 as the binary and arguments.
1001 **kwargs: kwargs is eventually passed on to Popen. If `command` is a string
1002 then will be invoked with `bash -c`, otherwise `command` is a list and
1003 will be invoked without a shell.
1004
1005 Returns:
1006 output: stdout as a string from the command.
1007
1008 Raises:
1009 CalledProcessError: on non-zero exit status
1010 """
1011 _, stdout, _ = self._cmd_status(cmd, raises=True, **kwargs)
1012 return stdout
1013
1014 def cmd_nostatus_nsonly(self, cmd, **kwargs):
1015 # Make sure the command runs on the host and not in any container.
1016 return self.cmd_nostatus(cmd, ns_only=True, **kwargs)
1017
1018 def cmd_status_nsonly(self, cmd, **kwargs):
1019 # Make sure the command runs on the host and not in any container.
1020 return self._cmd_status(cmd, ns_only=True, **kwargs)
1021
1022 def cmd_raises_nsonly(self, cmd, **kwargs):
1023 # Make sure the command runs on the host and not in any container.
1024 _, stdout, _ = self._cmd_status(cmd, raises=True, ns_only=True, **kwargs)
1025 return stdout
1026
1027 async def async_cmd_status(self, cmd, **kwargs):
1028 """Run given command returning status and outputs.
1029
1030 Args:
1031 cmd: `str` or `list` of the command to execute. If a string is given
1032 it is run using a shell, otherwise the list is executed directly
1033 as the binary and arguments.
1034 **kwargs: kwargs is eventually passed on to create_subprocess_exec. If
1035 `cmd` is a string then will be invoked with `bash -c`, otherwise
1036 `cmd` is a list and will be invoked without a shell.
1037
1038 Returns:
1039 (status, output, error) are returned
1040 status: the returncode of the command.
1041 output: stdout as a string from the command.
1042 error: stderr as a string from the command.
1043 """
1044 #
1045 # This method serves as the basis for all derived async cmd variations, so to
1046 # override async cmd behavior simply override this function and *not* the other
1047 # variations, unless you are changing only that variation's behavior
1048 #
1049 return await self._async_cmd_status(cmd, **kwargs)
1050
1051 async def async_cmd_nostatus(self, cmd, **kwargs):
1052 """Run given command returning output[s].
1053
1054 Args:
1055 cmd: `str` or `list` of the command to execute. If a string is given
1056 it is run using a shell, otherwise the list is executed directly
1057 as the binary and arguments.
1058 **kwargs: kwargs is eventually passed on to create_subprocess_exec. If
1059 `cmd` is a string then will be invoked with `bash -c`, otherwise
1060 `cmd` is a list and will be invoked without a shell.
1061
1062 Returns:
1063 if "stderr" is in kwargs and not equal to subprocess.STDOUT, then
1064 both stdout and stderr are returned, otherwise stderr is combined
1065 with stdout and only stdout is returned.
1066
1067 """
1068 # XXX change this back to _async_cmd_status instead of cmd_status when we
1069 # consolidate and cleanup the container overrides of *cmd_* functions
1070
1071 cmds = cmd
1072 if "stderr" in kwargs and kwargs["stderr"] != subprocess.STDOUT:
1073 _, o, e = await self._async_cmd_status(cmds, **kwargs)
1074 return o, e
1075 if "stderr" in kwargs:
1076 del kwargs["stderr"]
1077 _, o, _ = await self._async_cmd_status(cmds, stderr=subprocess.STDOUT, **kwargs)
1078 return o
1079
1080 async def async_cmd_raises(self, cmd, **kwargs):
1081 """Execute a command. Raise an exception on errors.
1082
1083 Args:
1084 cmd: `str` or `list` of the command to execute. If a string is given
1085 it is run using a shell, otherwise the list is executed directly
1086 as the binary and arguments.
1087 **kwargs: kwargs is eventually passed on to create_subprocess_exec. If
1088 `cmd` is a string then will be invoked with `bash -c`, otherwise
1089 `cmd` is a list and will be invoked without a shell.
1090
1091 Returns:
1092 output: stdout as a string from the command.
1093
1094 Raises:
1095 CalledProcessError: on non-zero exit status
1096 """
1097 _, stdout, _ = await self._async_cmd_status(cmd, raises=True, **kwargs)
1098 return stdout
1099
1100 async def async_cmd_status_nsonly(self, cmd, **kwargs):
1101 # Make sure the command runs on the host and not in any container.
1102 return await self._async_cmd_status(cmd, ns_only=True, **kwargs)
1103
1104 async def async_cmd_raises_nsonly(self, cmd, **kwargs):
1105 # Make sure the command runs on the host and not in any container.
1106 _, stdout, _ = await self._async_cmd_status(
1107 cmd, raises=True, ns_only=True, **kwargs
1108 )
1109 return stdout
1110
1111 def cmd_legacy(self, cmd, **kwargs):
1112 """Execute a command with stdout and stderr joined, *IGNORES ERROR*."""
1113 defaults = {"stderr": subprocess.STDOUT}
1114 defaults.update(kwargs)
1115 _, stdout, _ = self._cmd_status(cmd, raises=False, **defaults)
1116 return stdout
1117
1118 # Run a command in a new window (gnome-terminal, screen, tmux, xterm)
1119 def run_in_window(
1120 self,
1121 cmd,
1122 wait_for=False,
1123 background=False,
1124 name=None,
1125 title=None,
1126 forcex=False,
1127 new_window=False,
1128 tmux_target=None,
1129 ns_only=False,
1130 ):
1131 """Run a command in a new window (TMUX, Screen or XTerm).
1132
1133 Args:
1134 cmd: string to execute.
1135 wait_for: True to wait for exit from command or `str` as channel neme to
1136 signal on exit, otherwise False
1137 background: Do not change focus to new window.
1138 title: Title for new pane (tmux) or window (xterm).
1139 name: Name of the new window (tmux)
1140 forcex: Force use of X11.
1141 new_window: Open new window (instead of pane) in TMUX
1142 tmux_target: Target for tmux pane.
1143
1144 Returns:
1145 the pane/window identifier from TMUX (depends on `new_window`)
1146 """
1147 channel = None
1148 if isinstance(wait_for, str):
1149 channel = wait_for
1150 elif wait_for is True:
1151 channel = "{}-wait-{}".format(our_pid, Commander.tmux_wait_gen)
1152 Commander.tmux_wait_gen += 1
1153
1154 if forcex or ("TMUX" not in os.environ and "STY" not in os.environ):
1155 root_level = False
1156 else:
1157 root_level = True
1158
1159 # SUDO: The important thing to note is that with all these methods we are
1160 # executing on the users windowing system, so even though we are normally
1161 # running as root, we will not be when the command is dispatched. Also
1162 # in the case of SCREEN and X11 we need to sudo *back* to the user as well
1163 # This is also done by SSHRemote by defualt so we should *not* sudo back
1164 # if we are SSHRemote.
1165
1166 # XXX need to test ssh in screen
1167 # XXX need to test ssh in Xterm
1168 sudo_path = get_exec_path_host(["sudo"])
1169 # This first test case seems same as last but using list instead of string?
1170 if self.is_vm and self.use_ssh: # pylint: disable=E1101
1171 if isinstance(cmd, str):
1172 cmd = shlex.split(cmd)
1173 cmd = ["/usr/bin/env", f"MUNET_NODENAME={self.name}"] + cmd
1174
1175 # get the ssh cmd
1176 cmd = self._get_pre_cmd(False, True, ns_only=ns_only) + [shlex.join(cmd)]
1177 unet = self.unet # pylint: disable=E1101
1178 uns_cmd = unet._get_pre_cmd( # pylint: disable=W0212
1179 False, True, ns_only=True, root_level=root_level
1180 )
1181 # get the nsenter for munet
1182 nscmd = [
1183 sudo_path,
1184 *uns_cmd,
1185 *cmd,
1186 ]
1187 else:
1188 # This is the command to execute to be inside the namespace.
1189 # We are getting into trouble with quoting.
1190 # Why aren't we passing in MUNET_RUNDIR?
1191 cmd = f"/usr/bin/env MUNET_NODENAME={self.name} {cmd}"
1192 # We need sudo b/c we are executing as the user inside the window system.
1193 sudo_path = get_exec_path_host(["sudo"])
1194 nscmd = (
1195 sudo_path
1196 + " "
1197 + self._get_pre_cmd(True, True, ns_only=ns_only, root_level=root_level)
1198 + " "
1199 + cmd
1200 )
1201
1202 if "TMUX" in os.environ and not forcex:
1203 cmd = [get_exec_path_host("tmux")]
1204 if new_window:
1205 cmd.append("new-window")
1206 cmd.append("-P")
1207 if name:
1208 cmd.append("-n")
1209 cmd.append(name)
1210 if tmux_target:
1211 cmd.append("-t")
1212 cmd.append(tmux_target)
1213 else:
1214 cmd.append("split-window")
1215 cmd.append("-P")
1216 cmd.append("-h")
1217 if not tmux_target:
1218 tmux_target = os.getenv("TMUX_PANE", "")
1219 if background:
1220 cmd.append("-d")
1221 if tmux_target:
1222 cmd.append("-t")
1223 cmd.append(tmux_target)
1224
1225 # nscmd is always added as single string argument
1226 if not isinstance(nscmd, str):
1227 nscmd = shlex.join(nscmd)
1228 if title:
1229 nscmd = f"printf '\033]2;{title}\033\\'; {nscmd}"
1230 if channel:
1231 nscmd = f'trap "tmux wait -S {channel}; exit 0" EXIT; {nscmd}'
1232 cmd.append(nscmd)
1233
1234 elif "STY" in os.environ and not forcex:
1235 # wait for not supported in screen for now
1236 channel = None
1237 cmd = [get_exec_path_host("screen")]
1238 if not os.path.exists(
1239 "/run/screen/S-{}/{}".format(os.environ["USER"], os.environ["STY"])
1240 ):
1241 # XXX not appropriate for ssh
1242 cmd = ["sudo", "-Eu", os.environ["SUDO_USER"]] + cmd
1243
1244 if title:
1245 cmd.append("-t")
1246 cmd.append(title)
1247
1248 if isinstance(nscmd, str):
1249 nscmd = shlex.split(nscmd)
1250 cmd.extend(nscmd)
1251 elif "DISPLAY" in os.environ:
1252 cmd = [get_exec_path_host("xterm")]
1253 if "SUDO_USER" in os.environ:
1254 # Do this b/c making things work as root with xauth seems hard
1255 cmd = [
1256 get_exec_path_host("sudo"),
1257 "-Eu",
1258 os.environ["SUDO_USER"],
1259 ] + cmd
1260 if title:
1261 cmd.append("-T")
1262 cmd.append(title)
1263
1264 cmd.append("-e")
1265 if isinstance(nscmd, str):
1266 cmd.extend(shlex.split(nscmd))
1267 else:
1268 cmd.extend(nscmd)
1269
1270 # if channel:
1271 # return self.cmd_raises(cmd, skip_pre_cmd=True)
1272 # else:
1273 p = commander.popen(
1274 cmd,
1275 # skip_pre_cmd=True,
1276 stdin=None,
1277 shell=False,
1278 )
1279 # We should reap the child and report the error then.
1280 time_mod.sleep(2)
1281 if p.poll() is not None:
1282 self.logger.error("%s: Failed to launch xterm: %s", self, comm_error(p))
1283 return p
1284 else:
1285 self.logger.error(
1286 "DISPLAY, STY, and TMUX not in environment, can't open window"
1287 )
1288 raise Exception("Window requestd but TMUX, Screen and X11 not available")
1289
1290 # pane_info = self.cmd_raises(cmd, skip_pre_cmd=True, ns_only=True).strip()
1291 # We are prepending the nsenter command, so use unet.rootcmd
1292 pane_info = commander.cmd_raises(cmd).strip()
1293
1294 # Re-adjust the layout
1295 if "TMUX" in os.environ:
1296 cmd = [
1297 get_exec_path_host("tmux"),
1298 "select-layout",
1299 "-t",
1300 pane_info if not tmux_target else tmux_target,
1301 "tiled",
1302 ]
1303 commander.cmd_status(cmd)
1304
1305 # Wait here if we weren't handed the channel to wait for
1306 if channel and wait_for is True:
1307 cmd = [get_exec_path_host("tmux"), "wait", channel]
1308 # commander.cmd_status(cmd, skip_pre_cmd=True)
1309 commander.cmd_status(cmd)
1310
1311 return pane_info
1312
1313 def delete(self):
1314 """Calls self.async_delete within an exec loop."""
1315 asyncio.run(self.async_delete())
1316
1317 async def _async_delete(self):
1318 """Delete this objects resources.
1319
1320 This is the actual implementation of the resource cleanup, each class
1321 should cleanup it's own resources, generally catching and reporting,
1322 but not reraising any exceptions for it's own cleanup, then it should
1323 invoke `super()._async_delete() without catching any exceptions raised
1324 therein. See other examples in `base.py` or `native.py`
1325 """
1326 self.logger.info("%s: deleted", self)
1327
1328 async def async_delete(self):
1329 """Delete the Commander (or derived object).
1330
1331 The actual implementation for any class should be in `_async_delete`
1332 new derived classes should look at the documentation for that function.
1333 """
1334 try:
1335 self.deleting = True
1336 await self._async_delete()
1337 except Exception as error:
1338 self.logger.error("%s: error while deleting: %s", self, error)
1339
1340
1341 class InterfaceMixin:
1342 """A mixin class to support interface functionality."""
1343
1344 def __init__(self, *args, **kwargs):
1345 # del kwargs # get rid of lint
1346 # logging.warning("InterfaceMixin: args: %s kwargs: %s", args, kwargs)
1347
1348 self._intf_addrs = defaultdict(lambda: [None, None])
1349 self.net_intfs = {}
1350 self.next_intf_index = 0
1351 self.basename = "eth"
1352 # self.basename = name + "-eth"
1353 super().__init__(*args, **kwargs)
1354
1355 @property
1356 def intfs(self):
1357 return sorted(self._intf_addrs.keys())
1358
1359 @property
1360 def networks(self):
1361 return sorted(self.net_intfs.keys())
1362
1363 def get_intf_addr(self, ifname, ipv6=False):
1364 if ifname not in self._intf_addrs:
1365 return None
1366 return self._intf_addrs[ifname][bool(ipv6)]
1367
1368 def set_intf_addr(self, ifname, ifaddr):
1369 ifaddr = ipaddress.ip_interface(ifaddr)
1370 self._intf_addrs[ifname][ifaddr.version == 6] = ifaddr
1371
1372 def net_addr(self, netname, ipv6=False):
1373 if netname not in self.net_intfs:
1374 return None
1375 return self.get_intf_addr(self.net_intfs[netname], ipv6=ipv6)
1376
1377 def set_intf_basename(self, basename):
1378 self.basename = basename
1379
1380 def get_next_intf_name(self):
1381 while True:
1382 ifname = self.basename + str(self.next_intf_index)
1383 self.next_intf_index += 1
1384 if ifname not in self._intf_addrs:
1385 break
1386 return ifname
1387
1388 def get_ns_ifname(self, ifname):
1389 """Return a namespace unique interface name.
1390
1391 This function is primarily overriden by L3QemuVM, IOW by any class
1392 that doesn't create it's own network namespace and will share that
1393 with the root (unet) namespace.
1394
1395 Args:
1396 ifname: the interface name.
1397
1398 Returns:
1399 A name unique to the namespace of this object. By defualt the assumption
1400 is the ifname is namespace unique.
1401 """
1402 return ifname
1403
1404 def register_interface(self, ifname):
1405 if ifname not in self._intf_addrs:
1406 self._intf_addrs[ifname] = [None, None]
1407
1408 def register_network(self, netname, ifname):
1409 if netname in self.net_intfs:
1410 assert self.net_intfs[netname] == ifname
1411 else:
1412 self.net_intfs[netname] = ifname
1413
1414 def get_linux_tc_args(self, ifname, config):
1415 """Get interface constraints (jitter, delay, rate) for linux TC.
1416
1417 The keys and their values are as follows:
1418
1419 delay (int): number of microseconds
1420 jitter (int): number of microseconds
1421 jitter-correlation (float): % correlation to previous (default 10%)
1422 loss (float): % of loss
1423 loss-correlation (float): % correlation to previous (default 0%)
1424 rate (int or str): bits per second, string allows for use of
1425 {KMGTKiMiGiTi} prefixes "i" means K == 1024 otherwise K == 1000
1426 """
1427 del ifname # unused
1428
1429 netem_args = ""
1430
1431 def get_number(c, v, d=None):
1432 if v not in c or c[v] is None:
1433 return d
1434 return convert_number(c[v])
1435
1436 delay = get_number(config, "delay")
1437 if delay is not None:
1438 netem_args += f" delay {delay}usec"
1439
1440 jitter = get_number(config, "jitter")
1441 if jitter is not None:
1442 if not delay:
1443 raise ValueError("jitter but no delay specified")
1444 jitter_correlation = get_number(config, "jitter-correlation", 10)
1445 netem_args += f" {jitter}usec {jitter_correlation}%"
1446
1447 loss = get_number(config, "loss")
1448 if loss is not None:
1449 loss_correlation = get_number(config, "loss-correlation", 0)
1450 if loss_correlation:
1451 netem_args += f" loss {loss}% {loss_correlation}%"
1452 else:
1453 netem_args += f" loss {loss}%"
1454
1455 if (o_rate := config.get("rate")) is None:
1456 return netem_args, ""
1457
1458 #
1459 # This comment is not correct, but is trying to talk through/learn the
1460 # machinery.
1461 #
1462 # tokens arrive at `rate` into token buffer.
1463 # limit - number of bytes that can be queued waiting for tokens
1464 # -or-
1465 # latency - maximum amount of time a packet may sit in TBF queue
1466 #
1467 # So this just allows receiving faster than rate for latency amount of
1468 # time, before dropping.
1469 #
1470 # latency = sizeofbucket(limit) / rate (peakrate?)
1471 #
1472 # 32kbit
1473 # -------- = latency = 320ms
1474 # 100kbps
1475 #
1476 # -but then-
1477 # burst ([token] buffer) the largest number of instantaneous
1478 # tokens available (i.e, bucket size).
1479
1480 tbf_args = ""
1481 DEFLIMIT = 1518 * 1
1482 DEFBURST = 1518 * 2
1483 try:
1484 tc_rate = o_rate["rate"]
1485 tc_rate = convert_number(tc_rate)
1486 limit = convert_number(o_rate.get("limit", DEFLIMIT))
1487 burst = convert_number(o_rate.get("burst", DEFBURST))
1488 except (KeyError, TypeError):
1489 tc_rate = convert_number(o_rate)
1490 limit = convert_number(DEFLIMIT)
1491 burst = convert_number(DEFBURST)
1492 tbf_args += f" rate {tc_rate/1000}kbit"
1493 if delay:
1494 # give an extra 1/10 of buffer space to handle delay
1495 tbf_args += f" limit {limit} burst {burst}"
1496 else:
1497 tbf_args += f" limit {limit} burst {burst}"
1498
1499 return netem_args, tbf_args
1500
1501 def set_intf_constraints(self, ifname, **constraints):
1502 """Set interface outbound constraints.
1503
1504 Set outbound constraints (jitter, delay, rate) for an interface. All arguments
1505 may also be passed as a string and will be converted to numerical format. All
1506 arguments are also optional. If not specified then that existing constraint will
1507 be cleared.
1508
1509 Args:
1510 ifname: the name of the interface
1511 delay (int): number of microseconds.
1512 jitter (int): number of microseconds.
1513 jitter-correlation (float): Percent correlation to previous (default 10%).
1514 loss (float): Percent of loss.
1515 loss-correlation (float): Percent correlation to previous (default 25%).
1516 rate (int): bits per second, string allows for use of
1517 {KMGTKiMiGiTi} prefixes "i" means K == 1024 otherwise K == 1000.
1518 """
1519 nsifname = self.get_ns_ifname(ifname)
1520 netem_args, tbf_args = self.get_linux_tc_args(nsifname, constraints)
1521 count = 1
1522 selector = f"root handle {count}:"
1523 if netem_args:
1524 self.cmd_raises(
1525 f"tc qdisc add dev {nsifname} {selector} netem {netem_args}"
1526 )
1527 count += 1
1528 selector = f"parent {count-1}: handle {count}"
1529 # Place rate limit after delay otherwise limit/burst too complex
1530 if tbf_args:
1531 self.cmd_raises(f"tc qdisc add dev {nsifname} {selector} tbf {tbf_args}")
1532
1533 self.cmd_raises(f"tc qdisc show dev {nsifname}")
1534
1535
1536 class LinuxNamespace(Commander, InterfaceMixin):
1537 """A linux Namespace.
1538
1539 An object that creates and executes commands in a linux namespace
1540 """
1541
1542 def __init__(
1543 self,
1544 name,
1545 net=True,
1546 mount=True,
1547 uts=True,
1548 cgroup=False,
1549 ipc=False,
1550 pid=False,
1551 time=False,
1552 user=False,
1553 unshare_inline=False,
1554 set_hostname=True,
1555 private_mounts=None,
1556 **kwargs,
1557 ):
1558 """Create a new linux namespace.
1559
1560 Args:
1561 name: Internal name for the namespace.
1562 net: Create network namespace.
1563 mount: Create network namespace.
1564 uts: Create UTS (hostname) namespace.
1565 cgroup: Create cgroup namespace.
1566 ipc: Create IPC namespace.
1567 pid: Create PID namespace, also mounts new /proc.
1568 time: Create time namespace.
1569 user: Create user namespace, also keeps capabilities.
1570 set_hostname: Set the hostname to `name`, uts must also be True.
1571 private_mounts: List of strings of the form
1572 "[/external/path:]/internal/path. If no external path is specified a
1573 tmpfs is mounted on the internal path. Any paths specified are first
1574 passed to `mkdir -p`.
1575 unshare_inline: Unshare the process itself rather than using a proxy.
1576 logger: Passed to superclass.
1577 """
1578 # logging.warning("LinuxNamespace: name %s kwargs %s", name, kwargs)
1579
1580 super().__init__(name, **kwargs)
1581
1582 unet = self.unet
1583
1584 self.logger.debug("%s: creating", self)
1585
1586 self.cwd = os.path.abspath(os.getcwd())
1587
1588 self.nsflags = []
1589 self.ifnetns = {}
1590 self.uflags = 0
1591 self.p_ns_fds = None
1592 self.p_ns_fnames = None
1593 self.pid_ns = False
1594 self.init_pid = None
1595 self.unshare_inline = unshare_inline
1596 self.nsenter_fork = True
1597
1598 #
1599 # Collect the namespaces to unshare
1600 #
1601 if hasattr(self, "proc_path") and self.proc_path: # pylint: disable=no-member
1602 pp = Path(self.proc_path) # pylint: disable=no-member
1603 else:
1604 pp = unet.proc_path if unet else Path("/proc")
1605 pp = pp.joinpath("%P%", "ns")
1606
1607 flags = ""
1608 uflags = 0
1609 nslist = []
1610 nsflags = []
1611 if cgroup:
1612 nselm = "cgroup"
1613 nslist.append(nselm)
1614 nsflags.append(f"--{nselm}={pp / nselm}")
1615 flags += "C"
1616 uflags |= linux.CLONE_NEWCGROUP
1617 if ipc:
1618 nselm = "ipc"
1619 nslist.append(nselm)
1620 nsflags.append(f"--{nselm}={pp / nselm}")
1621 flags += "i"
1622 uflags |= linux.CLONE_NEWIPC
1623 if mount or pid:
1624 # We need a new mount namespace for pid
1625 nselm = "mnt"
1626 nslist.append(nselm)
1627 nsflags.append(f"--mount={pp / nselm}")
1628 mount = True
1629 flags += "m"
1630 uflags |= linux.CLONE_NEWNS
1631 if net:
1632 nselm = "net"
1633 nslist.append(nselm)
1634 nsflags.append(f"--{nselm}={pp / nselm}")
1635 # if pid:
1636 # os.system(f"touch /tmp/netns-{name}")
1637 # cmd.append(f"--net=/tmp/netns-{name}")
1638 # else:
1639 flags += "n"
1640 uflags |= linux.CLONE_NEWNET
1641 if pid:
1642 self.pid_ns = True
1643 # We look for this b/c the unshare pid will share with /sibn/init
1644 nselm = "pid_for_children"
1645 nslist.append(nselm)
1646 nsflags.append(f"--pid={pp / nselm}")
1647 flags += "p"
1648 uflags |= linux.CLONE_NEWPID
1649 if time:
1650 nselm = "time"
1651 # XXX time_for_children?
1652 nslist.append(nselm)
1653 nsflags.append(f"--{nselm}={pp / nselm}")
1654 flags += "T"
1655 uflags |= linux.CLONE_NEWTIME
1656 if user:
1657 nselm = "user"
1658 nslist.append(nselm)
1659 nsflags.append(f"--{nselm}={pp / nselm}")
1660 flags += "U"
1661 uflags |= linux.CLONE_NEWUSER
1662 if uts:
1663 nselm = "uts"
1664 nslist.append(nselm)
1665 nsflags.append(f"--{nselm}={pp / nselm}")
1666 flags += "u"
1667 uflags |= linux.CLONE_NEWUTS
1668
1669 assert flags, "LinuxNamespace with no namespaces requested"
1670
1671 # Should look path up using resources maybe...
1672 mutini_path = get_our_script_path("mutini")
1673 if not mutini_path:
1674 mutini_path = get_our_script_path("mutini.py")
1675 assert mutini_path
1676 cmd = [mutini_path, f"--unshare-flags={flags}", "-v"]
1677 fname = fsafe_name(self.name) + "-mutini.log"
1678 fname = (unet or self).rundir.joinpath(fname)
1679 stdout = open(fname, "w", encoding="utf-8")
1680 stderr = subprocess.STDOUT
1681
1682 #
1683 # Save the current namespace info to compare against later
1684 #
1685
1686 if not unet:
1687 nsdict = {x: os.readlink(f"/proc/self/ns/{x}") for x in nslist}
1688 else:
1689 nsdict = {
1690 x: os.readlink(f"{unet.proc_path}/{unet.pid}/ns/{x}") for x in nslist
1691 }
1692
1693 #
1694 # (A) Basically we need to save the pid of the unshare call for nsenter.
1695 #
1696 # For `unet is not None` (node case) the level this exists at is based on wether
1697 # unet is using a forking nsenter or not. So if unet.nsenter_fork == True then
1698 # we need the child pid of the p.pid (child of pid returned to us), otherwise
1699 # unet.nsenter_fork == False and we just use p.pid as it will be unshare after
1700 # nsenter exec's it.
1701 #
1702 # For the `unet is None` (unet case) the unshare is at the top level or
1703 # non-existent so we always save the returned p.pid. If we are unshare_inline we
1704 # won't have a __pre_cmd but we can save our child_pid to kill later, otherwise
1705 # we set unet.pid to None b/c there's literally nothing to do.
1706 #
1707 # ---------------------------------------------------------------------------
1708 # Breakdown for nested (non-unet) namespace creation, and what PID
1709 # to use for __pre_cmd nsenter use.
1710 # ---------------------------------------------------------------------------
1711 #
1712 # tl;dr
1713 # - for non-inline unshare: Use BBB with pid_for_children, unless none/none
1714 # #then (AAA) returned
1715 # - for inline unshare: use returned pid (AAA) with pid_for_children
1716 #
1717 # All commands use unet.popen to launch the unshare of mutini or cat.
1718 # mutini for PID unshare, otherwise cat. AAA is the returned pid BBB is the
1719 # child of the returned.
1720 #
1721 # Unshare Variant
1722 # ---------------
1723 #
1724 # Here we are running mutini if we are creating new pid namespace workspace,
1725 # cat otherwise.
1726 #
1727 # [PID+PID] pid tree looks like this:
1728 #
1729 # PID NSPID PPID PGID
1730 # uuu - N/A uuu main unet process
1731 # AAA - uuu AAA nsenter (forking, from unet) (in unet namespaces -pid)
1732 # BBB - AAA AAA unshare --fork --kill-child (forking)
1733 # CCC 1 BBB CCC mutini (non-forking since it is pid 1 in new namespace)
1734 #
1735 # Use BBB if we use pid_for_children, CCC for all
1736 #
1737 # [PID+none] For non-pid workspace creation (but unet pid) we use cat and pid
1738 # tree looks like this:
1739 #
1740 # PID PPID PGID
1741 # uuu N/A uuu main unet process
1742 # AAA uuu AAA nsenter (forking) (in unet namespaces -pid)
1743 # BBB AAA AAA unshare -> cat (from unshare non-forking)
1744 #
1745 # Use BBB for all
1746 #
1747 # [none+PID] For pid workspace creation (but NOT unet pid) we use mutini and pid
1748 # tree looks like this:
1749 #
1750 # PID NSPID PPID PGID
1751 # uuu - N/A uuu main unet process
1752 # AAA - uuu AAA nsenter -> unshare --fork --kill-child
1753 # BBB 1 AAA AAA mutini (non-forking since it is pid 1 in new namespace)
1754 #
1755 # Use AAA if we use pid_for_children, BBB for all
1756 #
1757 # [none+none] For non-pid workspace and non-pid unet we use cat and pid tree
1758 # looks like this:
1759 #
1760 # PID PPID PGID
1761 # uuu N/A uuu main unet process
1762 # AAA uuu AAA nsenter -> unshare -> cat
1763 #
1764 # Use AAA for all, there's no BBB
1765 #
1766 # Inline-Unshare Variant
1767 # ----------------------
1768 #
1769 # For unshare_inline and new PID namespace we have unshared all but our PID
1770 # namespace, but our children end up in the new namespace so the fork popen
1771 # does is good enough.
1772 #
1773 # [PID+PID] pid tree looks like this:
1774 #
1775 # PID NSPID PPID PGID
1776 # uuu - N/A uuu main unet process
1777 # AAA - uuu AAA unshare --fork --kill-child (forking)
1778 # BBB 1 AAA BBB mutini
1779 #
1780 # Use AAA if we use pid_for_children, BBB for all
1781 #
1782 # [PID+none] For non-pid workspace creation (but unet pid) we use cat and pid
1783 # tree looks like this:
1784 #
1785 # PID PPID PGID
1786 # uuu N/A uuu main unet process
1787 # AAA uuu AAA unshare -> cat
1788 #
1789 # Use AAA for all
1790 #
1791 # [none+PID] For pid workspace creation (but NOT unet pid) we use mutini and pid
1792 # tree looks like this:
1793 #
1794 # PID NSPID PPID PGID
1795 # uuu - N/A uuu main unet process
1796 # AAA - uuu AAA unshare --fork --kill-child
1797 # BBB 1 AAA BBB mutini
1798 #
1799 # Use AAA if we use pid_for_children, BBB for all
1800 #
1801 # [none+none] For non-pid workspace and non-pid unet we use cat and pid tree
1802 # looks like this:
1803 #
1804 # PID PPID PGID
1805 # uuu N/A uuu main unet process
1806 # AAA uuu AAA unshare -> cat
1807 #
1808 # Use AAA for all.
1809 #
1810 #
1811 # ---------------------------------------------------------------------------
1812 # Breakdown for unet namespace creation, and what PID to use for __pre_cmd
1813 # ---------------------------------------------------------------------------
1814 #
1815 # tl;dr: save returned PID or nothing.
1816 # - for non-inline unshare: Use AAA with pid_for_children (returned pid)
1817 # - for inline unshare: no __precmd as the fork in popen is enough.
1818 #
1819 # Use commander to launch the unshare mutini/cat (for PID/none
1820 # workspace PID) for non-inline case. AAA is the returned pid BBB is the child
1821 # of the returned.
1822 #
1823 # Unshare Variant
1824 # ---------------
1825 #
1826 # Here we are running mutini if we are creating new pid namespace workspace,
1827 # cat otherwise.
1828 #
1829 # [PID] for unet pid creation pid tree looks like this:
1830 #
1831 # PID NSPID PPID PGID
1832 # uuu - N/A uuu main unet process
1833 # AAA - uuu AAA unshare --fork --kill-child (forking)
1834 # BBB 1 AAA BBB mutini
1835 #
1836 # Use AAA if we use pid_for_children, BBB for all
1837 #
1838 # [none] for unet non-pid, pid tree looks like this:
1839 #
1840 # PID PPID PGID
1841 # uuu N/A uuu main unet process
1842 # AAA uuu AAA unshare -> cat
1843 #
1844 # Use AAA for all
1845 #
1846 # Inline-Unshare Variant
1847 # -----------------------
1848 #
1849 # For unshare_inline and new PID namespace we have unshared all but our PID
1850 # namespace, but our children end up in the new namespace so the fork in popen
1851 # does is good enough.
1852 #
1853 # [PID] for unet pid creation pid tree looks like this:
1854 #
1855 # PID NSPID PPID PGID
1856 # uuu - N/A uuu main unet process
1857 # AAA 1 uuu AAA mutini
1858 #
1859 # Save p / p.pid, but don't configure any nsenter, uneeded.
1860 #
1861 # Use nothing as the fork when doing a popen is enough to be in all the right
1862 # namepsaces.
1863 #
1864 # [none] for unet non-pid, pid tree looks like this:
1865 #
1866 # PID PPID PGID
1867 # uuu N/A uuu main unet process
1868 #
1869 # Nothing, no __pre_cmd.
1870 #
1871 #
1872
1873 self.ppid = os.getppid()
1874 self.unshare_inline = unshare_inline
1875 if unshare_inline:
1876 assert unet is None
1877 self.uflags = uflags
1878 #
1879 # Open file descriptors for current namespaces for later resotration.
1880 #
1881 try:
1882 kversion = [int(x) for x in platform.release().split("-")[0].split(".")]
1883 kvok = kversion[0] > 5 or (kversion[0] == 5 and kversion[1] >= 8)
1884 except ValueError:
1885 kvok = False
1886 if (
1887 not kvok
1888 or sys.version_info[0] < 3
1889 or (sys.version_info[0] == 3 and sys.version_info[1] < 9)
1890 ):
1891 # get list of namespace file descriptors before we unshare
1892 self.p_ns_fds = []
1893 self.p_ns_fnames = []
1894 tmpflags = uflags
1895 for i in range(0, 64):
1896 v = 1 << i
1897 if (tmpflags & v) == 0:
1898 continue
1899 tmpflags &= ~v
1900 if v in linux.namespace_files:
1901 path = os.path.join("/proc/self", linux.namespace_files[v])
1902 if os.path.exists(path):
1903 self.p_ns_fds.append(os.open(path, 0))
1904 self.p_ns_fnames.append(f"{path} -> {os.readlink(path)}")
1905 self.logger.debug(
1906 "%s: saving old namespace fd %s (%s)",
1907 self,
1908 self.p_ns_fnames[-1],
1909 self.p_ns_fds[-1],
1910 )
1911 if not tmpflags:
1912 break
1913 else:
1914 self.p_ns_fds = None
1915 self.p_ns_fnames = None
1916 self.ppid_fd = linux.pidfd_open(self.ppid)
1917
1918 self.logger.debug(
1919 "%s: unshare to new namespaces %s",
1920 self,
1921 linux.clone_flag_string(uflags),
1922 )
1923
1924 linux.unshare(uflags)
1925
1926 if not pid:
1927 p = None
1928 self.pid = None
1929 self.nsenter_fork = False
1930 else:
1931 # Need to fork to create the PID namespace, but we need to continue
1932 # running from the parent so that things like pytest work. We'll execute
1933 # a mutini process to manage the child init 1 duties.
1934 #
1935 # We (the parent pid) can no longer create threads, due to that being
1936 # restricted by the kernel. See EINVAL in clone(2).
1937 #
1938 p = commander.popen(
1939 [mutini_path, "-v"],
1940 stdin=subprocess.PIPE,
1941 stdout=stdout,
1942 stderr=stderr,
1943 text=True,
1944 # new session/pgid so signals don't propagate
1945 start_new_session=True,
1946 shell=False,
1947 )
1948 self.pid = p.pid
1949 self.nsenter_fork = False
1950 else:
1951 # Using cat and a stdin PIPE is nice as it will exit when we do. However,
1952 # we also detach it from the pgid so that signals do not propagate to it.
1953 # This is b/c it would exit early (e.g., ^C) then, at least the main munet
1954 # proc which has no other processes like frr daemons running, will take the
1955 # main network namespace with it, which will remove the bridges and the
1956 # veth pair (because the bridge side veth is deleted).
1957 self.logger.debug("%s: creating namespace process: %s", self, cmd)
1958
1959 # Use the parent unet process if we have one this will cause us to inherit
1960 # the namespaces correctly even in the non-inline case.
1961 parent = self.unet if self.unet else commander
1962
1963 p = parent.popen(
1964 cmd,
1965 stdin=subprocess.PIPE,
1966 stdout=stdout,
1967 stderr=stderr,
1968 text=True,
1969 start_new_session=not unet,
1970 shell=False,
1971 )
1972
1973 # The pid number returned is in the global pid namespace. For unshare_inline
1974 # this can be unfortunate b/c our /proc has been remounted in our new pid
1975 # namespace and won't contain global pid namespace pids. To solve for this
1976 # we get all the pid values for the process below.
1977
1978 # See (A) above for when we need the child pid.
1979 self.logger.debug("%s: namespace process: %s", self, proc_str(p))
1980 self.pid = p.pid
1981 if unet and unet.nsenter_fork:
1982 assert not unet.unshare_inline
1983 # Need child pid of p.pid
1984 pgrep = unet.rootcmd.get_exec_path("pgrep")
1985 # a sing fork was done
1986 child_pid = unet.rootcmd.cmd_raises([pgrep, "-o", "-P", str(p.pid)])
1987 self.pid = int(child_pid.strip())
1988 self.logger.debug("%s: child of namespace process: %s", self, pid)
1989
1990 self.p = p
1991
1992 # Let's always have a valid value.
1993 if self.pid is None:
1994 self.pid = our_pid
1995
1996 #
1997 # Let's find all our pids in the nested PID namespaces
1998 #
1999 if unet:
2000 proc_path = unet.proc_path
2001 else:
2002 proc_path = self.proc_path if hasattr(self, "proc_path") else "/proc"
2003 proc_path = f"{proc_path}/{self.pid}"
2004
2005 pid_status = open(f"{proc_path}/status", "r", encoding="ascii").read()
2006 m = re.search(r"\nNSpid:((?:\t[0-9]+)+)\n", pid_status)
2007 self.pids = [int(x) for x in m.group(1).strip().split("\t")]
2008 assert self.pids[0] == self.pid
2009
2010 self.logger.debug("%s: namespace scoped pids: %s", self, self.pids)
2011
2012 # -----------------------------------------------
2013 # Now let's wait until unshare completes it's job
2014 # -----------------------------------------------
2015 timeout = Timeout(30)
2016 if self.pid is not None and self.pid != our_pid:
2017 while (not p or not p.poll()) and not timeout.is_expired():
2018 # check new namespace values against old (nsdict), unshare
2019 # can actually take a bit to complete.
2020 for fname in tuple(nslist):
2021 # self.pid will be the global pid b/c we didn't unshare_inline
2022 nspath = f"{proc_path}/ns/{fname}"
2023 try:
2024 nsf = os.readlink(nspath)
2025 except OSError as error:
2026 self.logger.debug(
2027 "unswitched: error (ok) checking %s: %s", nspath, error
2028 )
2029 continue
2030 if nsdict[fname] != nsf:
2031 self.logger.debug(
2032 "switched: original %s current %s", nsdict[fname], nsf
2033 )
2034 nslist.remove(fname)
2035 elif unshare_inline:
2036 logging.warning(
2037 "unshare_inline not unshared %s == %s", nsdict[fname], nsf
2038 )
2039 else:
2040 self.logger.debug(
2041 "unswitched: current %s elapsed: %s", nsf, timeout.elapsed()
2042 )
2043 if not nslist:
2044 self.logger.debug(
2045 "all done waiting for unshare after %s", timeout.elapsed()
2046 )
2047 break
2048
2049 elapsed = int(timeout.elapsed())
2050 if elapsed <= 3:
2051 time_mod.sleep(0.1)
2052 else:
2053 self.logger.info(
2054 "%s: unshare taking more than %ss: %s", self, elapsed, nslist
2055 )
2056 time_mod.sleep(1)
2057
2058 if p is not None and p.poll():
2059 self.logger.error("%s: namespace process failed: %s", self, comm_error(p))
2060 assert p.poll() is None, "unshare failed"
2061
2062 #
2063 # Setup the pre-command to enter the target namespace from the running munet
2064 # process using self.pid
2065 #
2066
2067 if pid:
2068 nsenter_fork = True
2069 elif unet and unet.nsenter_fork:
2070 # if unet created a pid namespace we need to enter it since we aren't
2071 # entering a child pid namespace we created for the node. Otherwise
2072 # we have a /proc remounted under unet, but our process is running in
2073 # the root pid namepsace
2074 nselm = "pid_for_children"
2075 nsflags.append(f"--pid={pp / nselm}")
2076 nsenter_fork = True
2077 else:
2078 # We dont need a fork.
2079 nsflags.append("-F")
2080 nsenter_fork = False
2081
2082 # Save nsenter values if running from root namespace
2083 # we need this for the unshare_inline case when run externally (e.g., from
2084 # within tmux server).
2085 root_nsflags = [x.replace("%P%", str(self.pid)) for x in nsflags]
2086 self.__root_base_pre_cmd = ["/usr/bin/nsenter", *root_nsflags]
2087 self.__root_pre_cmd = list(self.__root_base_pre_cmd)
2088
2089 if unshare_inline:
2090 assert unet is None
2091 # We have nothing to do here since our process is now in the correct
2092 # namespaces and children will inherit from us, even the PID namespace will
2093 # be corrent b/c commands are run by first forking.
2094 self.nsenter_fork = False
2095 self.nsflags = []
2096 self.__base_pre_cmd = []
2097 else:
2098 # We will use nsenter
2099 self.nsenter_fork = nsenter_fork
2100 self.nsflags = nsflags
2101 self.__base_pre_cmd = list(self.__root_base_pre_cmd)
2102
2103 self.__pre_cmd = list(self.__base_pre_cmd)
2104
2105 # Always mark new mount namespaces as recursive private
2106 if mount:
2107 # if self.p is None and not pid:
2108 self.cmd_raises_nsonly("mount --make-rprivate /")
2109
2110 # We need to remount the procfs for the new PID namespace, since we aren't using
2111 # unshare(1) which does that for us.
2112 if pid and unshare_inline:
2113 assert mount
2114 self.cmd_raises_nsonly("mount -t proc proc /proc")
2115
2116 # We do not want cmd_status in child classes (e.g., container) for
2117 # the remaining setup calls in this __init__ function.
2118
2119 if net:
2120 # Remount /sys to pickup any changes in the network, but keep root
2121 # /sys/fs/cgroup. This pattern could be made generic and supported for any
2122 # overlapping mounts
2123 if mount:
2124 tmpmnt = f"/tmp/cgm-{self.pid}"
2125 self.cmd_status_nsonly(
2126 f"mkdir {tmpmnt} && mount --rbind /sys/fs/cgroup {tmpmnt}"
2127 )
2128 rc = o = e = None
2129 for i in range(0, 10):
2130 rc, o, e = self.cmd_status_nsonly(
2131 "mount -t sysfs sysfs /sys", warn=False
2132 )
2133 if not rc:
2134 break
2135 self.logger.debug(
2136 "got error mounting new sysfs will retry: %s",
2137 cmd_error(rc, o, e),
2138 )
2139 time_mod.sleep(1)
2140 else:
2141 raise Exception(cmd_error(rc, o, e))
2142
2143 self.cmd_status_nsonly(
2144 f"mount --move {tmpmnt} /sys/fs/cgroup && rmdir {tmpmnt}"
2145 )
2146
2147 # Original micronet code
2148 # self.cmd_raises_nsonly("mount -t sysfs sysfs /sys")
2149 # self.cmd_raises_nsonly(
2150 # "mount -o rw,nosuid,nodev,noexec,relatime "
2151 # "-t cgroup2 cgroup /sys/fs/cgroup"
2152 # )
2153
2154 # Set the hostname to the namespace name
2155 if uts and set_hostname:
2156 self.cmd_status_nsonly("hostname " + self.name)
2157 nroot = subprocess.check_output("hostname")
2158 if unshare_inline or (unet and unet.unshare_inline):
2159 assert (
2160 root_hostname != nroot
2161 ), f'hostname unchanged from "{nroot}" wanted "{self.name}"'
2162 else:
2163 # Assert that we didn't just change the host hostname
2164 assert (
2165 root_hostname == nroot
2166 ), f'root hostname "{root_hostname}" changed to "{nroot}"!'
2167
2168 if private_mounts:
2169 if isinstance(private_mounts, str):
2170 private_mounts = [private_mounts]
2171 for m in private_mounts:
2172 s = m.split(":", 1)
2173 if len(s) == 1:
2174 self.tmpfs_mount(s[0])
2175 else:
2176 self.bind_mount(s[0], s[1])
2177
2178 # this will fail if running inside the namespace with PID
2179 if pid:
2180 o = self.cmd_nostatus_nsonly("ls -l /proc/1/ns")
2181 else:
2182 o = self.cmd_nostatus_nsonly("ls -l /proc/self/ns")
2183
2184 self.logger.debug("namespaces:\n %s", o)
2185
2186 # will cache the path, which is important in delete to avoid running a shell
2187 # which can hang during cleanup
2188 self.ip_path = get_exec_path_host("ip")
2189 if net:
2190 self.cmd_status_nsonly([self.ip_path, "link", "set", "lo", "up"])
2191
2192 self.logger.info("%s: created", self)
2193
2194 def _get_pre_cmd(self, use_str, use_pty, ns_only=False, root_level=False, **kwargs):
2195 """Get the pre-user-command values.
2196
2197 The values returned here should be what is required to cause the user's command
2198 to execute in the correct context (e.g., namespace, container, sshremote).
2199 """
2200 del kwargs
2201 del ns_only
2202 del use_pty
2203 pre_cmd = self.__root_pre_cmd if root_level else self.__pre_cmd
2204 return shlex.join(pre_cmd) if use_str else list(pre_cmd)
2205
2206 def tmpfs_mount(self, inner):
2207 self.logger.debug("Mounting tmpfs on %s", inner)
2208 self.cmd_raises("mkdir -p " + inner)
2209 self.cmd_raises("mount -n -t tmpfs tmpfs " + inner)
2210
2211 def bind_mount(self, outer, inner):
2212 self.logger.debug("Bind mounting %s on %s", outer, inner)
2213 if commander.test("-f", outer):
2214 self.cmd_raises(f"mkdir -p {os.path.dirname(inner)} && touch {inner}")
2215 else:
2216 if not commander.test("-e", outer):
2217 commander.cmd_raises_nsonly(f"mkdir -p {outer}")
2218 self.cmd_raises(f"mkdir -p {inner}")
2219 self.cmd_raises("mount --rbind {} {} ".format(outer, inner))
2220
2221 def add_netns(self, ns):
2222 self.logger.debug("Adding network namespace %s", ns)
2223
2224 if os.path.exists("/run/netns/{}".format(ns)):
2225 self.logger.warning("%s: Removing existing nsspace %s", self, ns)
2226 try:
2227 self.delete_netns(ns)
2228 except Exception as ex:
2229 self.logger.warning(
2230 "%s: Couldn't remove existing nsspace %s: %s",
2231 self,
2232 ns,
2233 str(ex),
2234 exc_info=True,
2235 )
2236 self.cmd_raises_nsonly([self.ip_path, "netns", "add", ns])
2237
2238 def delete_netns(self, ns):
2239 self.logger.debug("Deleting network namespace %s", ns)
2240 self.cmd_raises_nsonly([self.ip_path, "netns", "delete", ns])
2241
2242 def set_intf_netns(self, intf, ns, up=False):
2243 # In case a user hard-codes 1 thinking it "resets"
2244 ns = str(ns)
2245 if ns == "1":
2246 ns = str(self.pid)
2247
2248 self.logger.debug("Moving interface %s to namespace %s", intf, ns)
2249
2250 cmd = [self.ip_path, "link", "set", intf, "netns", ns]
2251 if up:
2252 cmd.append("up")
2253 self.intf_ip_cmd(intf, cmd)
2254 if ns == str(self.pid):
2255 # If we are returning then remove from dict
2256 if intf in self.ifnetns:
2257 del self.ifnetns[intf]
2258 else:
2259 self.ifnetns[intf] = ns
2260
2261 def reset_intf_netns(self, intf):
2262 self.logger.debug("Moving interface %s to default namespace", intf)
2263 self.set_intf_netns(intf, str(self.pid))
2264
2265 def intf_ip_cmd(self, intf, cmd):
2266 """Run an ip command, considering an interface's possible namespace."""
2267 if intf in self.ifnetns:
2268 if isinstance(cmd, list):
2269 assert cmd[0].endswith("ip")
2270 cmd[1:1] = ["-n", self.ifnetns[intf]]
2271 else:
2272 assert cmd.startswith("ip ")
2273 cmd = "ip -n " + self.ifnetns[intf] + cmd[2:]
2274 self.cmd_raises_nsonly(cmd)
2275
2276 def intf_tc_cmd(self, intf, cmd):
2277 """Run a tc command, considering an interface's possible namespace."""
2278 if intf in self.ifnetns:
2279 if isinstance(cmd, list):
2280 assert cmd[0].endswith("tc")
2281 cmd[1:1] = ["-n", self.ifnetns[intf]]
2282 else:
2283 assert cmd.startswith("tc ")
2284 cmd = "tc -n " + self.ifnetns[intf] + cmd[2:]
2285 self.cmd_raises_nsonly(cmd)
2286
2287 def set_ns_cwd(self, cwd: Union[str, Path]):
2288 """Common code for changing pre_cmd and pre_nscmd."""
2289 self.logger.debug("%s: new CWD %s", self, cwd)
2290 self.__root_pre_cmd = self.__root_base_pre_cmd + ["--wd=" + str(cwd)]
2291 if self.__pre_cmd:
2292 self.__pre_cmd = self.__base_pre_cmd + ["--wd=" + str(cwd)]
2293 elif self.unshare_inline:
2294 os.chdir(cwd)
2295
2296 async def _async_delete(self):
2297 if type(self) == LinuxNamespace: # pylint: disable=C0123
2298 self.logger.info("%s: deleting", self)
2299 else:
2300 self.logger.debug("%s: LinuxNamespace sub-class deleting", self)
2301
2302 # Signal pid namespace proc to exit
2303 if (
2304 (self.p is None or self.p.pid != self.pid)
2305 and self.pid
2306 and self.pid != our_pid
2307 ):
2308 self.logger.debug(
2309 "cleanup pid on separate pid %s from proc pid %s",
2310 self.pid,
2311 self.p.pid if self.p else None,
2312 )
2313 await self.cleanup_pid(self.pid)
2314
2315 if self.p is not None:
2316 self.logger.debug("cleanup proc pid %s", self.p.pid)
2317 await self.async_cleanup_proc(self.p)
2318
2319 # return to the previous namespace, need to do this in case anothe munet
2320 # is being created, especially when it plans to inherit the parent's (host)
2321 # namespace.
2322 if self.uflags:
2323 logging.info("restoring from inline unshare: cwd: %s", os.getcwd())
2324 # This only works in linux>=5.8
2325 if self.p_ns_fds is None:
2326 self.logger.debug(
2327 "%s: restoring namespaces %s",
2328 self,
2329 linux.clone_flag_string(self.uflags),
2330 )
2331 # fd = linux.pidfd_open(self.ppid)
2332 fd = self.ppid_fd
2333 retry = 3
2334 for i in range(0, retry):
2335 try:
2336 linux.setns(fd, self.uflags)
2337 except OSError as error:
2338 self.logger.warning(
2339 "%s: could not reset to old namespace fd %s: %s",
2340 self,
2341 fd,
2342 error,
2343 )
2344 if i == retry - 1:
2345 raise
2346 time_mod.sleep(1)
2347 os.close(fd)
2348 else:
2349 while self.p_ns_fds:
2350 fd = self.p_ns_fds.pop()
2351 fname = self.p_ns_fnames.pop()
2352 self.logger.debug(
2353 "%s: restoring namespace from fd %s (%s)", self, fname, fd
2354 )
2355 retry = 3
2356 for i in range(0, retry):
2357 try:
2358 linux.setns(fd, 0)
2359 break
2360 except OSError as error:
2361 self.logger.warning(
2362 "%s: could not reset to old namespace fd %s (%s): %s",
2363 self,
2364 fname,
2365 fd,
2366 error,
2367 )
2368 if i == retry - 1:
2369 raise
2370 time_mod.sleep(1)
2371 os.close(fd)
2372 self.p_ns_fds = None
2373 self.p_ns_fnames = None
2374 logging.info("restored from unshare: cwd: %s", os.getcwd())
2375
2376 self.__root_base_pre_cmd = ["/bin/false"]
2377 self.__base_pre_cmd = ["/bin/false"]
2378 self.__root_pre_cmd = ["/bin/false"]
2379 self.__pre_cmd = ["/bin/false"]
2380
2381 await super()._async_delete()
2382
2383
2384 class SharedNamespace(Commander):
2385 """Share another namespace.
2386
2387 An object that executes commands in an existing pid's linux namespace
2388 """
2389
2390 def __init__(self, name, pid=None, nsflags=None, **kwargs):
2391 """Share a linux namespace.
2392
2393 Args:
2394 name: Internal name for the namespace.
2395 pid: PID of the process to share with.
2396 nsflags: nsenter flags to pass to inherit namespaces from
2397 """
2398 super().__init__(name, **kwargs)
2399
2400 self.logger.debug("%s: Creating", self)
2401
2402 self.cwd = os.path.abspath(os.getcwd())
2403 self.pid = pid if pid is not None else our_pid
2404
2405 nsflags = (x.replace("%P%", str(self.pid)) for x in nsflags) if nsflags else []
2406 self.__base_pre_cmd = ["/usr/bin/nsenter", *nsflags] if nsflags else []
2407 self.__pre_cmd = self.__base_pre_cmd
2408 self.ip_path = self.get_exec_path("ip")
2409
2410 def _get_pre_cmd(self, use_str, use_pty, ns_only=False, root_level=False, **kwargs):
2411 """Get the pre-user-command values.
2412
2413 The values returned here should be what is required to cause the user's command
2414 to execute in the correct context (e.g., namespace, container, sshremote).
2415 """
2416 del kwargs
2417 del ns_only
2418 del use_pty
2419 assert not root_level
2420 return shlex.join(self.__pre_cmd) if use_str else list(self.__pre_cmd)
2421
2422 def set_ns_cwd(self, cwd: Union[str, Path]):
2423 """Common code for changing pre_cmd and pre_nscmd."""
2424 self.logger.debug("%s: new CWD %s", self, cwd)
2425 self.__pre_cmd = self.__base_pre_cmd + ["--wd=" + str(cwd)]
2426
2427
2428 class Bridge(SharedNamespace, InterfaceMixin):
2429 """A linux bridge."""
2430
2431 next_ord = 1
2432
2433 @classmethod
2434 def _get_next_id(cls):
2435 # Do not use `cls` here b/c that makes the variable class specific
2436 n = Bridge.next_ord
2437 Bridge.next_ord = n + 1
2438 return n
2439
2440 def __init__(self, name=None, mtu=None, unet=None, **kwargs):
2441 """Create a linux Bridge."""
2442 self.id = self._get_next_id()
2443 if not name:
2444 name = "br{}".format(self.id)
2445
2446 unet_pid = our_pid if unet.pid is None else unet.pid
2447
2448 super().__init__(name, pid=unet_pid, nsflags=unet.nsflags, unet=unet, **kwargs)
2449
2450 self.set_intf_basename(self.name + "-e")
2451
2452 self.mtu = mtu
2453
2454 self.logger.debug("Bridge: Creating")
2455
2456 assert len(self.name) <= 16 # Make sure fits in IFNAMSIZE
2457 self.cmd_raises(f"ip link delete {name} || true")
2458 self.cmd_raises(f"ip link add {name} type bridge")
2459 if self.mtu:
2460 self.cmd_raises(f"ip link set {name} mtu {self.mtu}")
2461 self.cmd_raises(f"ip link set {name} up")
2462
2463 self.logger.debug("%s: Created, Running", self)
2464
2465 def get_ifname(self, netname):
2466 return self.net_intfs[netname] if netname in self.net_intfs else None
2467
2468 async def _async_delete(self):
2469 """Stop the bridge (i.e., delete the linux resources)."""
2470 if type(self) == Bridge: # pylint: disable=C0123
2471 self.logger.info("%s: deleting", self)
2472 else:
2473 self.logger.debug("%s: Bridge sub-class deleting", self)
2474
2475 rc, o, e = await self.async_cmd_status(
2476 [self.ip_path, "link", "show", self.name],
2477 stdin=subprocess.DEVNULL,
2478 start_new_session=True,
2479 warn=False,
2480 )
2481 if not rc:
2482 rc, o, e = await self.async_cmd_status(
2483 [self.ip_path, "link", "delete", self.name],
2484 stdin=subprocess.DEVNULL,
2485 start_new_session=True,
2486 warn=False,
2487 )
2488 if rc:
2489 self.logger.error(
2490 "%s: error deleting bridge %s: %s",
2491 self,
2492 self.name,
2493 cmd_error(rc, o, e),
2494 )
2495 await super()._async_delete()
2496
2497
2498 class BaseMunet(LinuxNamespace):
2499 """Munet."""
2500
2501 def __init__(
2502 self,
2503 name="munet",
2504 isolated=True,
2505 pid=True,
2506 rundir=None,
2507 pytestconfig=None,
2508 **kwargs,
2509 ):
2510 """Create a Munet."""
2511 # logging.warning("BaseMunet: %s", name)
2512
2513 self.hosts = {}
2514 self.switches = {}
2515 self.links = {}
2516 self.macs = {}
2517 self.rmacs = {}
2518 self.isolated = isolated
2519
2520 self.cli_server = None
2521 self.cli_sockpath = None
2522 self.cli_histfile = None
2523 self.cli_in_window_cmds = {}
2524 self.cli_run_cmds = {}
2525
2526 #
2527 # We need a directory for various files
2528 #
2529 if not rundir:
2530 rundir = "/tmp/munet"
2531 self.rundir = Path(rundir)
2532
2533 #
2534 # Always having a global /proc is required to keep things from exploding
2535 # complexity with nested new pid namespaces..
2536 #
2537 if pid:
2538 self.proc_path = Path(tempfile.mkdtemp(suffix="-proc", prefix="mu-"))
2539 logging.debug("%s: mounting /proc on proc_path %s", name, self.proc_path)
2540 linux.mount("proc", str(self.proc_path), "proc")
2541 else:
2542 self.proc_path = Path("/proc")
2543
2544 #
2545 # Now create a root level commander that works regardless of whether we inline
2546 # unshare or not. Save it in the global variable as well
2547 #
2548
2549 if not self.isolated:
2550 self.rootcmd = commander
2551 elif not pid:
2552 nsflags = (
2553 f"--mount={self.proc_path / '1/ns/mnt'}",
2554 f"--net={self.proc_path / '1/ns/net'}",
2555 f"--uts={self.proc_path / '1/ns/uts'}",
2556 # f"--ipc={self.proc_path / '1/ns/ipc'}",
2557 # f"--time={self.proc_path / '1/ns/time'}",
2558 # f"--cgroup={self.proc_path / '1/ns/cgroup'}",
2559 )
2560 self.rootcmd = SharedNamespace("root", pid=1, nsflags=nsflags)
2561 else:
2562 # XXX user
2563 nsflags = (
2564 # XXX Backing up PID namespace just doesn't work.
2565 # f"--pid={self.proc_path / '1/ns/pid_for_children'}",
2566 f"--mount={self.proc_path / '1/ns/mnt'}",
2567 f"--net={self.proc_path / '1/ns/net'}",
2568 f"--uts={self.proc_path / '1/ns/uts'}",
2569 # f"--ipc={self.proc_path / '1/ns/ipc'}",
2570 # f"--time={self.proc_path / '1/ns/time'}",
2571 # f"--cgroup={self.proc_path / '1/ns/cgroup'}",
2572 )
2573 self.rootcmd = SharedNamespace("root", pid=1, nsflags=nsflags)
2574 global roothost # pylint: disable=global-statement
2575
2576 roothost = self.rootcmd
2577
2578 self.cfgopt = munet_config.ConfigOptionsProxy(pytestconfig)
2579
2580 super().__init__(
2581 name, mount=True, net=isolated, uts=isolated, pid=pid, unet=None, **kwargs
2582 )
2583
2584 # This allows us to cleanup any leftover running munet's
2585 if "MUNET_PID" in os.environ:
2586 if os.environ["MUNET_PID"] != str(our_pid):
2587 logging.error(
2588 "Found env MUNET_PID != our pid %s, instead its %s, changing",
2589 our_pid,
2590 os.environ["MUNET_PID"],
2591 )
2592 os.environ["MUNET_PID"] = str(our_pid)
2593
2594 # this is for testing purposes do not use
2595 if not BaseMunet.g_unet:
2596 BaseMunet.g_unet = self
2597
2598 self.logger.debug("%s: Creating", self)
2599
2600 def __getitem__(self, key):
2601 if key in self.switches:
2602 return self.switches[key]
2603 return self.hosts[key]
2604
2605 def add_host(self, name, cls=LinuxNamespace, **kwargs):
2606 """Add a host to munet."""
2607 self.logger.debug("%s: add_host %s(%s)", self, cls.__name__, name)
2608
2609 self.hosts[name] = cls(name, unet=self, **kwargs)
2610
2611 # Create a new mounted FS for tracking nested network namespaces creatd by the
2612 # user with `ip netns add`
2613
2614 # XXX why is this failing with podman???
2615 # self.hosts[name].tmpfs_mount("/run/netns")
2616
2617 return self.hosts[name]
2618
2619 def add_link(self, node1, node2, if1, if2, mtu=None, **intf_constraints):
2620 """Add a link between switch and node or 2 nodes.
2621
2622 If constraints are given they are applied to each endpoint. See
2623 `InterfaceMixin::set_intf_constraints()` for more info.
2624 """
2625 isp2p = False
2626
2627 try:
2628 name1 = node1.name
2629 except AttributeError:
2630 if node1 in self.switches:
2631 node1 = self.switches[node1]
2632 else:
2633 node1 = self.hosts[node1]
2634 name1 = node1.name
2635
2636 try:
2637 name2 = node2.name
2638 except AttributeError:
2639 if node2 in self.switches:
2640 node2 = self.switches[node2]
2641 else:
2642 node2 = self.hosts[node2]
2643 name2 = node2.name
2644
2645 if name1 in self.switches:
2646 assert name2 in self.hosts
2647 elif name2 in self.switches:
2648 assert name1 in self.hosts
2649 name1, name2 = name2, name1
2650 if1, if2 = if2, if1
2651 else:
2652 # p2p link
2653 assert name1 in self.hosts
2654 assert name2 in self.hosts
2655 isp2p = True
2656
2657 lname = "{}:{}-{}:{}".format(name1, if1, name2, if2)
2658 self.logger.debug("%s: add_link %s%s", self, lname, " p2p" if isp2p else "")
2659 self.links[lname] = (name1, if1, name2, if2)
2660
2661 # And create the veth now.
2662 if isp2p:
2663 lhost, rhost = self.hosts[name1], self.hosts[name2]
2664 lifname = "i1{:x}".format(lhost.pid)
2665
2666 # Done at root level
2667 nsif1 = lhost.get_ns_ifname(if1)
2668 nsif2 = rhost.get_ns_ifname(if2)
2669
2670 # Use pids[-1] to get the unet scoped pid for hosts
2671 self.cmd_raises_nsonly(
2672 f"ip link add {lifname} type veth peer name {nsif2}"
2673 f" netns {rhost.pids[-1]}"
2674 )
2675 self.cmd_raises_nsonly(f"ip link set {lifname} netns {lhost.pids[-1]}")
2676
2677 lhost.cmd_raises_nsonly("ip link set {} name {}".format(lifname, nsif1))
2678 if mtu:
2679 lhost.cmd_raises_nsonly("ip link set {} mtu {}".format(nsif1, mtu))
2680 lhost.cmd_raises_nsonly("ip link set {} up".format(nsif1))
2681 lhost.register_interface(if1)
2682
2683 if mtu:
2684 rhost.cmd_raises_nsonly("ip link set {} mtu {}".format(nsif2, mtu))
2685 rhost.cmd_raises_nsonly("ip link set {} up".format(nsif2))
2686 rhost.register_interface(if2)
2687 else:
2688 switch = self.switches[name1]
2689 rhost = self.hosts[name2]
2690
2691 nsif1 = switch.get_ns_ifname(if1)
2692 nsif2 = rhost.get_ns_ifname(if2)
2693
2694 if mtu is None:
2695 mtu = switch.mtu
2696
2697 if len(nsif1) > 16:
2698 self.logger.error('"%s" len %s > 16', nsif1, len(nsif1))
2699 elif len(nsif2) > 16:
2700 self.logger.error('"%s" len %s > 16', nsif2, len(nsif2))
2701 assert len(nsif1) <= 16 and len(nsif2) <= 16 # Make sure fits in IFNAMSIZE
2702
2703 self.logger.debug("%s: Creating veth pair for link %s", self, lname)
2704
2705 # Use pids[-1] to get the unet scoped pid for hosts
2706 # switch is already in our namespace so nothing to convert.
2707 self.cmd_raises_nsonly(
2708 f"ip link add {nsif1} type veth peer name {nsif2}"
2709 f" netns {rhost.pids[-1]}"
2710 )
2711
2712 if mtu:
2713 # if switch.mtu:
2714 # # the switch interface should match the switch config
2715 # switch.cmd_raises_nsonly(
2716 # "ip link set {} mtu {}".format(if1, switch.mtu)
2717 # )
2718 switch.cmd_raises_nsonly("ip link set {} mtu {}".format(nsif1, mtu))
2719 rhost.cmd_raises_nsonly("ip link set {} mtu {}".format(nsif2, mtu))
2720
2721 switch.register_interface(if1)
2722 rhost.register_interface(if2)
2723 rhost.register_network(switch.name, if2)
2724
2725 switch.cmd_raises_nsonly(f"ip link set {nsif1} master {switch.name}")
2726
2727 switch.cmd_raises_nsonly(f"ip link set {nsif1} up")
2728 rhost.cmd_raises_nsonly(f"ip link set {nsif2} up")
2729
2730 # Cache the MAC values, and reverse mapping
2731 self.get_mac(name1, nsif1)
2732 self.get_mac(name2, nsif2)
2733
2734 # Setup interface constraints if provided
2735 if intf_constraints:
2736 node1.set_intf_constraints(if1, **intf_constraints)
2737 node2.set_intf_constraints(if2, **intf_constraints)
2738
2739 def add_switch(self, name, cls=Bridge, **kwargs):
2740 """Add a switch to munet."""
2741 self.logger.debug("%s: add_switch %s(%s)", self, cls.__name__, name)
2742 self.switches[name] = cls(name, unet=self, **kwargs)
2743 return self.switches[name]
2744
2745 def get_mac(self, name, ifname):
2746 if name in self.hosts:
2747 dev = self.hosts[name]
2748 else:
2749 dev = self.switches[name]
2750
2751 nsifname = self.get_ns_ifname(ifname)
2752
2753 if (name, ifname) not in self.macs:
2754 _, output, _ = dev.cmd_status_nsonly("ip -o link show " + nsifname)
2755 m = re.match(".*link/(loopback|ether) ([0-9a-fA-F:]+) .*", output)
2756 mac = m.group(2)
2757 self.macs[(name, ifname)] = mac
2758 self.rmacs[mac] = (name, ifname)
2759
2760 return self.macs[(name, ifname)]
2761
2762 async def _delete_link(self, lname):
2763 rname, rif = self.links[lname][2:4]
2764 host = self.hosts[rname]
2765 nsrif = host.get_ns_ifname(rif)
2766
2767 self.logger.debug("%s: Deleting veth pair for link %s", self, lname)
2768 rc, o, e = await host.async_cmd_status_nsonly(
2769 [self.ip_path, "link", "delete", nsrif],
2770 stdin=subprocess.DEVNULL,
2771 start_new_session=True,
2772 warn=False,
2773 )
2774 if rc:
2775 self.logger.error("Err del veth pair %s: %s", lname, cmd_error(rc, o, e))
2776
2777 async def _delete_links(self):
2778 # for x in self.links:
2779 # await self._delete_link(x)
2780 return await asyncio.gather(*[self._delete_link(x) for x in self.links])
2781
2782 async def _async_delete(self):
2783 """Delete the munet topology."""
2784 # logger = self.logger if False else logging
2785 logger = self.logger
2786 if type(self) == BaseMunet: # pylint: disable=C0123
2787 logger.info("%s: deleting.", self)
2788 else:
2789 logger.debug("%s: BaseMunet sub-class deleting.", self)
2790
2791 logger.debug("Deleting links")
2792 try:
2793 await self._delete_links()
2794 except Exception as error:
2795 logger.error("%s: error deleting links: %s", self, error, exc_info=True)
2796
2797 logger.debug("Deleting hosts and bridges")
2798 try:
2799 # Delete hosts and switches, wait for them all to complete
2800 # even if there is an exception.
2801 htask = [x.async_delete() for x in self.hosts.values()]
2802 stask = [x.async_delete() for x in self.switches.values()]
2803 await asyncio.gather(*htask, *stask, return_exceptions=True)
2804 except Exception as error:
2805 logger.error(
2806 "%s: error deleting hosts and switches: %s", self, error, exc_info=True
2807 )
2808
2809 self.links = {}
2810 self.hosts = {}
2811 self.switches = {}
2812
2813 try:
2814 if self.cli_server:
2815 self.cli_server.cancel()
2816 self.cli_server = None
2817 if self.cli_sockpath:
2818 await self.async_cmd_status(
2819 "rm -rf " + os.path.dirname(self.cli_sockpath)
2820 )
2821 self.cli_sockpath = None
2822 except Exception as error:
2823 logger.error(
2824 "%s: error cli server or sockpaths: %s", self, error, exc_info=True
2825 )
2826
2827 try:
2828 if self.cli_histfile:
2829 readline.write_history_file(self.cli_histfile)
2830 self.cli_histfile = None
2831 except Exception as error:
2832 logger.error(
2833 "%s: error saving history file: %s", self, error, exc_info=True
2834 )
2835
2836 # XXX for some reason setns during the delete is changing our dir to /.
2837 cwd = os.getcwd()
2838
2839 try:
2840 await super()._async_delete()
2841 except Exception as error:
2842 logger.error(
2843 "%s: error deleting parent classes: %s", self, error, exc_info=True
2844 )
2845 os.chdir(cwd)
2846
2847 try:
2848 if self.proc_path and str(self.proc_path) != "/proc":
2849 logger.debug("%s: umount, remove proc_path %s", self, self.proc_path)
2850 linux.umount(str(self.proc_path), 0)
2851 os.rmdir(self.proc_path)
2852 except Exception as error:
2853 logger.warning(
2854 "%s: error umount and removing proc_path %s: %s",
2855 self,
2856 self.proc_path,
2857 error,
2858 exc_info=True,
2859 )
2860 try:
2861 linux.umount(str(self.proc_path), linux.MNT_DETACH)
2862 except Exception as error2:
2863 logger.error(
2864 "%s: error umount with detach proc_path %s: %s",
2865 self,
2866 self.proc_path,
2867 error2,
2868 exc_info=True,
2869 )
2870
2871 if BaseMunet.g_unet == self:
2872 BaseMunet.g_unet = None
2873
2874
2875 BaseMunet.g_unet = None
2876
2877 if True: # pylint: disable=using-constant-test
2878
2879 class ShellWrapper:
2880 """A Read-Execute-Print-Loop (REPL) interface.
2881
2882 A newline or prompt changing command should be sent to the
2883 spawned child prior to creation as the `prompt` will be `expect`ed
2884 """
2885
2886 def __init__(
2887 self,
2888 spawn,
2889 prompt,
2890 continuation_prompt=None,
2891 extra_init_cmd=None,
2892 will_echo=False,
2893 escape_ansi=False,
2894 ):
2895 self.echo = will_echo
2896 self.escape = (
2897 re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]") if escape_ansi else None
2898 )
2899
2900 logging.debug(
2901 'ShellWraper: XXX prompt "%s" will_echo %s child.echo %s',
2902 prompt,
2903 will_echo,
2904 spawn.echo,
2905 )
2906
2907 self.child = spawn
2908 if self.child.echo:
2909 logging.info("Setting child to echo")
2910 self.child.setecho(False)
2911 self.child.waitnoecho()
2912 assert not self.child.echo
2913
2914 self.prompt = prompt
2915 self.cont_prompt = continuation_prompt
2916
2917 # Use expect_exact if we can as it should be faster
2918 self.expects = [prompt]
2919 if re.escape(prompt) == prompt and hasattr(self.child, "expect_exact"):
2920 self._expectf = self.child.expect_exact
2921 else:
2922 self._expectf = self.child.expect
2923 if continuation_prompt:
2924 self.expects.append(continuation_prompt)
2925 if re.escape(continuation_prompt) != continuation_prompt:
2926 self._expectf = self.child.expect
2927
2928 if extra_init_cmd:
2929 self.expect_prompt()
2930 self.child.sendline(extra_init_cmd)
2931 self.expect_prompt()
2932
2933 def expect_prompt(self, timeout=-1):
2934 return self._expectf(self.expects, timeout=timeout)
2935
2936 def run_command(self, command, timeout=-1):
2937 """Pexpect REPLWrapper compatible run_command.
2938
2939 This will split `command` into lines and feed each one to the shell.
2940
2941 Args:
2942 command: string of commands separated by newlines, a trailing
2943 newline will cause and empty line to be sent.
2944 timeout: pexpect timeout value.
2945 """
2946 lines = command.splitlines()
2947 if command[-1] == "\n":
2948 lines.append("")
2949 output = ""
2950 index = 0
2951 for line in lines:
2952 self.child.sendline(line)
2953 index = self.expect_prompt(timeout=timeout)
2954 output += self.child.before
2955
2956 if index:
2957 if hasattr(self.child, "kill"):
2958 self.child.kill(signal.SIGINT)
2959 else:
2960 self.child.send("\x03")
2961 self.expect_prompt(timeout=30 if self.child.timeout is None else -1)
2962 raise ValueError("Continuation prompt found at end of commands")
2963
2964 if self.escape:
2965 output = self.escape.sub("", output)
2966
2967 return output
2968
2969 def cmd_nostatus(self, cmd, timeout=-1):
2970 r"""Execute a shell command.
2971
2972 Returns:
2973 (strip/cleaned \r) output
2974 """
2975 output = self.run_command(cmd, timeout)
2976 output = output.replace("\r\n", "\n")
2977 if self.echo:
2978 # remove the command
2979 idx = output.find(cmd)
2980 if idx == -1:
2981 logging.warning(
2982 "Didn't find command ('%s') in expected output ('%s')",
2983 cmd,
2984 output,
2985 )
2986 else:
2987 # Remove up to and including the command from the output stream
2988 output = output[idx + len(cmd) :]
2989
2990 return output.replace("\r", "").strip()
2991
2992 def cmd_status(self, cmd, timeout=-1):
2993 r"""Execute a shell command.
2994
2995 Returns:
2996 status and (strip/cleaned \r) output
2997 """
2998 # Run the command getting the output
2999 output = self.cmd_nostatus(cmd, timeout)
3000
3001 # Now get the status
3002 scmd = "echo $?"
3003 rcstr = self.run_command(scmd)
3004 rcstr = rcstr.replace("\r\n", "\n")
3005 if self.echo:
3006 # remove the command
3007 idx = rcstr.find(scmd)
3008 if idx == -1:
3009 if self.echo:
3010 logging.warning(
3011 "Didn't find status ('%s') in expected output ('%s')",
3012 scmd,
3013 rcstr,
3014 )
3015 try:
3016 rc = int(rcstr)
3017 except Exception:
3018 rc = 255
3019 else:
3020 rcstr = rcstr[idx + len(scmd) :].strip()
3021 try:
3022 rc = int(rcstr)
3023 except ValueError as error:
3024 logging.error(
3025 "%s: error with expected status output: %s: %s",
3026 self,
3027 error,
3028 rcstr,
3029 exc_info=True,
3030 )
3031 rc = 255
3032 return rc, output
3033
3034 def cmd_raises(self, cmd, timeout=-1):
3035 r"""Execute a shell command.
3036
3037 Returns:
3038 (strip/cleaned \r) ouptut
3039
3040 Raises:
3041 CalledProcessError: on non-zero exit status
3042 """
3043 rc, output = self.cmd_status(cmd, timeout)
3044 if rc:
3045 raise CalledProcessError(rc, cmd, output)
3046 return output
3047
3048
3049 # ---------------------------
3050 # Root level utility function
3051 # ---------------------------
3052
3053
3054 def get_exec_path(binary):
3055 return commander.get_exec_path(binary)
3056
3057
3058 def get_exec_path_host(binary):
3059 return commander.get_exec_path(binary)
3060
3061
3062 def get_our_script_path(script):
3063 # would be nice to find this w/o using a path lookup
3064 sdir = os.path.dirname(os.path.abspath(__file__))
3065 spath = os.path.join(sdir, script)
3066 if os.path.exists(spath):
3067 return spath
3068 return get_exec_path(script)
3069
3070
3071 commander = Commander("munet")
3072 roothost = None