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