]>
Commit | Line | Data |
---|---|---|
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.""" | |
9 | import asyncio | |
10 | import datetime | |
11 | import errno | |
12 | import ipaddress | |
13 | import logging | |
14 | import os | |
15 | import platform | |
16 | import re | |
17 | import readline | |
18 | import shlex | |
19 | import signal | |
20 | import subprocess | |
21 | import sys | |
22 | import tempfile | |
23 | import time as time_mod | |
24 | ||
25 | from collections import defaultdict | |
26 | from pathlib import Path | |
27 | from typing import Union | |
28 | ||
67afd929 | 29 | from . import config as munet_config |
352ddc72 CH |
30 | from . import linux |
31 | ||
32 | ||
33 | try: | |
34 | import pexpect | |
35 | ||
36 | from pexpect.fdpexpect import fdspawn | |
37 | from pexpect.popen_spawn import PopenSpawn | |
38 | ||
39 | have_pexpect = True | |
40 | except ImportError: | |
41 | have_pexpect = False | |
42 | ||
43 | PEXPECT_PROMPT = "PEXPECT_PROMPT>" | |
44 | PEXPECT_CONTINUATION_PROMPT = "PEXPECT_PROMPT+" | |
45 | ||
46 | root_hostname = subprocess.check_output("hostname") | |
47 | our_pid = os.getpid() | |
48 | ||
49 | ||
8aba44e3 CH |
50 | detailed_cmd_logging = False |
51 | ||
52 | ||
352ddc72 CH |
53 | class MunetError(Exception): |
54 | """A generic munet error.""" | |
55 | ||
56 | ||
57 | class 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 | ||
74 | class 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 | ||
103 | def fsafe_name(name): | |
104 | return "".join(x if x.isalnum() else "_" for x in name) | |
105 | ||
106 | ||
107 | def indent(s): | |
108 | return "\t" + s.replace("\n", "\n\t") | |
109 | ||
110 | ||
111 | def 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 | ||
118 | def 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 |
125 | def 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 | ||
139 | def 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 |
146 | def 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 | ||
154 | def 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 | ||
167 | def 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 | ||
175 | async 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 | ||
183 | def 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 | ||
192 | def 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 | ||
221 | def is_file_like(fo): | |
222 | return isinstance(fo, int) or hasattr(fo, "fileno") | |
223 | ||
224 | ||
225 | def get_tc_bits_value(user_value): | |
226 | value = convert_number(user_value) / 1000 | |
227 | return f"{value:03f}kbit" | |
228 | ||
229 | ||
230 | def get_tc_bytes_value(user_value): | |
231 | # Raw numbers are bytes in tc | |
232 | return convert_number(user_value) | |
233 | ||
234 | ||
235 | def get_tmp_dir(uniq): | |
236 | return os.path.join(tempfile.mkdtemp(), uniq) | |
237 | ||
238 | ||
239 | async 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 | ||
255 | def _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 | ||
271 | def 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 | ||
308 | class 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 | ||
1380 | class 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 | ||
1575 | class 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 | ||
2423 | class 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 | ||
2467 | class 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 | ||
2537 | class 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 | ||
2914 | BaseMunet.g_unet = None | |
2915 | ||
2916 | if 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 | ||
3093 | def get_exec_path(binary): | |
3094 | return commander.get_exec_path(binary) | |
3095 | ||
3096 | ||
3097 | def get_exec_path_host(binary): | |
3098 | return commander.get_exec_path(binary) | |
3099 | ||
3100 | ||
3101 | def 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 | ||
3110 | commander = Commander("munet") | |
3111 | roothost = None |