]>
Commit | Line | Data |
---|---|---|
6a5433ef CH |
1 | # -*- coding: utf-8 eval: (blacken-mode 1) -*- |
2 | # | |
3 | # July 9 2021, Christian Hopps <chopps@labn.net> | |
4 | # | |
5 | # Copyright (c) 2021, LabN Consulting, L.L.C. | |
6 | # | |
7 | # This program is free software; you can redistribute it and/or | |
8 | # modify it under the terms of the GNU General Public License | |
9 | # as published by the Free Software Foundation; either version 2 | |
10 | # of the License, or (at your option) any later version. | |
11 | # | |
12 | # This program is distributed in the hope that it will be useful, | |
13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 | # GNU General Public License for more details. | |
16 | # | |
17 | # You should have received a copy of the GNU General Public License along | |
18 | # with this program; see the file COPYING; if not, write to the Free Software | |
19 | # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA | |
20 | # | |
21 | import datetime | |
22 | import logging | |
23 | import os | |
6a5433ef | 24 | import re |
6a5433ef CH |
25 | import shlex |
26 | import subprocess | |
27 | import sys | |
28 | import tempfile | |
29 | import time as time_mod | |
30 | import traceback | |
31 | ||
32 | root_hostname = subprocess.check_output("hostname") | |
33 | ||
34 | # This allows us to cleanup any leftovers later on | |
35 | os.environ["MICRONET_PID"] = str(os.getpid()) | |
36 | ||
37 | ||
38 | class Timeout(object): | |
39 | def __init__(self, delta): | |
40 | self.started_on = datetime.datetime.now() | |
41 | self.expires_on = self.started_on + datetime.timedelta(seconds=delta) | |
42 | ||
43 | def elapsed(self): | |
44 | elapsed = datetime.datetime.now() - self.started_on | |
45 | return elapsed.total_seconds() | |
46 | ||
47 | def is_expired(self): | |
48 | return datetime.datetime.now() > self.expires_on | |
49 | ||
50 | ||
51 | def is_string(value): | |
52 | """Return True if value is a string.""" | |
53 | try: | |
54 | return isinstance(value, basestring) # type: ignore | |
55 | except NameError: | |
56 | return isinstance(value, str) | |
57 | ||
58 | ||
59 | def shell_quote(command): | |
60 | """Return command wrapped in single quotes.""" | |
61 | if sys.version_info[0] >= 3: | |
62 | return shlex.quote(command) | |
63 | return "'{}'".format(command.replace("'", "'\"'\"'")) # type: ignore | |
64 | ||
65 | ||
66 | def cmd_error(rc, o, e): | |
67 | s = "rc {}".format(rc) | |
68 | o = "\n\tstdout: " + o.strip() if o and o.strip() else "" | |
69 | e = "\n\tstderr: " + e.strip() if e and e.strip() else "" | |
70 | return s + o + e | |
71 | ||
72 | ||
73 | def proc_error(p, o, e): | |
74 | args = p.args if is_string(p.args) else " ".join(p.args) | |
75 | s = "rc {} pid {}\n\targs: {}".format(p.returncode, p.pid, args) | |
76 | o = "\n\tstdout: " + o.strip() if o and o.strip() else "" | |
77 | e = "\n\tstderr: " + e.strip() if e and e.strip() else "" | |
78 | return s + o + e | |
79 | ||
80 | ||
81 | def comm_error(p): | |
82 | rc = p.poll() | |
83 | assert rc is not None | |
84 | if not hasattr(p, "saved_output"): | |
85 | p.saved_output = p.communicate() | |
86 | return proc_error(p, *p.saved_output) | |
87 | ||
88 | ||
89 | class Commander(object): # pylint: disable=R0205 | |
90 | """ | |
91 | Commander. | |
92 | ||
93 | An object that can execute commands. | |
94 | """ | |
95 | ||
96 | tmux_wait_gen = 0 | |
97 | ||
98 | def __init__(self, name, logger=None): | |
99 | """Create a Commander.""" | |
100 | self.name = name | |
101 | self.last = None | |
102 | self.exec_paths = {} | |
103 | self.pre_cmd = [] | |
104 | self.pre_cmd_str = "" | |
105 | ||
106 | if not logger: | |
107 | self.logger = logging.getLogger(__name__ + ".commander." + name) | |
108 | else: | |
109 | self.logger = logger | |
110 | ||
111 | self.cwd = self.cmd_raises("pwd").strip() | |
112 | ||
113 | def set_logger(self, logfile): | |
114 | self.logger = logging.getLogger(__name__ + ".commander." + self.name) | |
115 | if is_string(logfile): | |
116 | handler = logging.FileHandler(logfile, mode="w") | |
117 | else: | |
118 | handler = logging.StreamHandler(logfile) | |
119 | ||
120 | fmtstr = "%(asctime)s.%(msecs)03d %(levelname)s: {}({}): %(message)s".format( | |
121 | self.__class__.__name__, self.name | |
122 | ) | |
123 | handler.setFormatter(logging.Formatter(fmt=fmtstr)) | |
124 | self.logger.addHandler(handler) | |
125 | ||
126 | def set_pre_cmd(self, pre_cmd=None): | |
127 | if not pre_cmd: | |
128 | self.pre_cmd = [] | |
129 | self.pre_cmd_str = "" | |
130 | else: | |
131 | self.pre_cmd = pre_cmd | |
132 | self.pre_cmd_str = " ".join(self.pre_cmd) + " " | |
133 | ||
134 | def __str__(self): | |
135 | return "Commander({})".format(self.name) | |
136 | ||
137 | def get_exec_path(self, binary): | |
138 | """Return the full path to the binary executable. | |
139 | ||
140 | `binary` :: binary name or list of binary names | |
141 | """ | |
142 | if is_string(binary): | |
143 | bins = [binary] | |
144 | else: | |
145 | bins = binary | |
146 | for b in bins: | |
147 | if b in self.exec_paths: | |
148 | return self.exec_paths[b] | |
149 | ||
150 | rc, output, _ = self.cmd_status("which " + b, warn=False) | |
151 | if not rc: | |
152 | return os.path.abspath(output.strip()) | |
153 | return None | |
154 | ||
155 | def get_tmp_dir(self, uniq): | |
156 | return os.path.join(tempfile.mkdtemp(), uniq) | |
157 | ||
158 | def test(self, flags, arg): | |
159 | """Run test binary, with flags and arg""" | |
160 | test_path = self.get_exec_path(["test"]) | |
161 | rc, output, _ = self.cmd_status([test_path, flags, arg], warn=False) | |
162 | return not rc | |
163 | ||
164 | def path_exists(self, path): | |
165 | """Check if path exists.""" | |
166 | return self.test("-e", path) | |
167 | ||
168 | def _get_cmd_str(self, cmd): | |
169 | if is_string(cmd): | |
170 | return self.pre_cmd_str + cmd | |
171 | cmd = self.pre_cmd + cmd | |
172 | return " ".join(cmd) | |
173 | ||
174 | def _get_sub_args(self, cmd, defaults, **kwargs): | |
175 | if is_string(cmd): | |
176 | defaults["shell"] = True | |
177 | pre_cmd = self.pre_cmd_str | |
178 | else: | |
179 | defaults["shell"] = False | |
180 | pre_cmd = self.pre_cmd | |
181 | cmd = [str(x) for x in cmd] | |
182 | defaults.update(kwargs) | |
183 | return pre_cmd, cmd, defaults | |
184 | ||
185 | def _popen(self, method, cmd, skip_pre_cmd=False, **kwargs): | |
186 | if sys.version_info[0] >= 3: | |
187 | defaults = { | |
188 | "encoding": "utf-8", | |
189 | "stdout": subprocess.PIPE, | |
190 | "stderr": subprocess.PIPE, | |
191 | } | |
192 | else: | |
193 | defaults = { | |
194 | "stdout": subprocess.PIPE, | |
195 | "stderr": subprocess.PIPE, | |
196 | } | |
197 | pre_cmd, cmd, defaults = self._get_sub_args(cmd, defaults, **kwargs) | |
198 | ||
199 | self.logger.debug('%s: %s("%s", kwargs: %s)', self, method, cmd, defaults) | |
200 | ||
201 | actual_cmd = cmd if skip_pre_cmd else pre_cmd + cmd | |
202 | p = subprocess.Popen(actual_cmd, **defaults) | |
203 | if not hasattr(p, "args"): | |
204 | p.args = actual_cmd | |
205 | return p, actual_cmd | |
206 | ||
207 | def set_cwd(self, cwd): | |
208 | self.logger.warning("%s: 'cd' (%s) does not work outside namespaces", self, cwd) | |
209 | self.cwd = cwd | |
210 | ||
211 | def popen(self, cmd, **kwargs): | |
212 | """ | |
213 | Creates a pipe with the given `command`. | |
214 | ||
215 | Args: | |
216 | command: `str` or `list` of command to open a pipe with. | |
217 | **kwargs: kwargs is eventually passed on to Popen. If `command` is a string | |
218 | then will be invoked with shell=True, otherwise `command` is a list and | |
219 | will be invoked with shell=False. | |
220 | ||
221 | Returns: | |
222 | a subprocess.Popen object. | |
223 | """ | |
224 | p, _ = self._popen("popen", cmd, **kwargs) | |
225 | return p | |
226 | ||
227 | def cmd_status(self, cmd, raises=False, warn=True, stdin=None, **kwargs): | |
228 | """Execute a command.""" | |
229 | ||
230 | # We are not a shell like mininet, so we need to intercept this | |
231 | chdir = False | |
232 | if not is_string(cmd): | |
233 | cmds = cmd | |
234 | else: | |
235 | # XXX we can drop this when the code stops assuming it works | |
236 | m = re.match(r"cd(\s*|\s+(\S+))$", cmd) | |
237 | if m and m.group(2): | |
238 | self.logger.warning( | |
239 | "Bad call to 'cd' (chdir) emulating, use self.set_cwd():\n%s", | |
240 | "".join(traceback.format_stack(limit=12)), | |
241 | ) | |
242 | assert is_string(cmd) | |
243 | chdir = True | |
244 | cmd += " && pwd" | |
245 | ||
246 | # If we are going to run under bash then we don't need shell=True! | |
247 | cmds = ["/bin/bash", "-c", cmd] | |
248 | ||
249 | pinput = None | |
250 | ||
251 | if is_string(stdin) or isinstance(stdin, bytes): | |
252 | pinput = stdin | |
253 | stdin = subprocess.PIPE | |
254 | ||
255 | p, actual_cmd = self._popen("cmd_status", cmds, stdin=stdin, **kwargs) | |
256 | stdout, stderr = p.communicate(input=pinput) | |
257 | rc = p.wait() | |
258 | ||
259 | # For debugging purposes. | |
260 | self.last = (rc, actual_cmd, cmd, stdout, stderr) | |
261 | ||
262 | if rc: | |
263 | if warn: | |
264 | self.logger.warning( | |
265 | "%s: proc failed: %s:", self, proc_error(p, stdout, stderr) | |
266 | ) | |
267 | if raises: | |
268 | # error = Exception("stderr: {}".format(stderr)) | |
4c98b89e | 269 | # This annoyingly doesn't' show stderr when printed normally |
6a5433ef CH |
270 | error = subprocess.CalledProcessError(rc, actual_cmd) |
271 | error.stdout, error.stderr = stdout, stderr | |
272 | raise error | |
273 | elif chdir: | |
274 | self.set_cwd(stdout.strip()) | |
275 | ||
276 | return rc, stdout, stderr | |
277 | ||
278 | def cmd_legacy(self, cmd, **kwargs): | |
279 | """Execute a command with stdout and stderr joined, *IGNORES ERROR*.""" | |
280 | ||
281 | defaults = {"stderr": subprocess.STDOUT} | |
282 | defaults.update(kwargs) | |
283 | _, stdout, _ = self.cmd_status(cmd, raises=False, **defaults) | |
284 | return stdout | |
285 | ||
286 | def cmd_raises(self, cmd, **kwargs): | |
287 | """Execute a command. Raise an exception on errors""" | |
288 | ||
289 | rc, stdout, _ = self.cmd_status(cmd, raises=True, **kwargs) | |
290 | assert rc == 0 | |
291 | return stdout | |
292 | ||
293 | # Run a command in a new window (gnome-terminal, screen, tmux, xterm) | |
294 | def run_in_window( | |
295 | self, | |
296 | cmd, | |
297 | wait_for=False, | |
298 | background=False, | |
299 | name=None, | |
300 | title=None, | |
301 | forcex=False, | |
302 | new_window=False, | |
303 | tmux_target=None, | |
304 | ): | |
305 | """ | |
306 | Run a command in a new window (TMUX, Screen or XTerm). | |
307 | ||
308 | Args: | |
309 | wait_for: True to wait for exit from command or `str` as channel neme to signal on exit, otherwise False | |
310 | background: Do not change focus to new window. | |
311 | title: Title for new pane (tmux) or window (xterm). | |
312 | name: Name of the new window (tmux) | |
313 | forcex: Force use of X11. | |
314 | new_window: Open new window (instead of pane) in TMUX | |
315 | tmux_target: Target for tmux pane. | |
316 | ||
317 | Returns: | |
318 | the pane/window identifier from TMUX (depends on `new_window`) | |
319 | """ | |
320 | ||
321 | channel = None | |
322 | if is_string(wait_for): | |
323 | channel = wait_for | |
324 | elif wait_for is True: | |
325 | channel = "{}-wait-{}".format(os.getpid(), Commander.tmux_wait_gen) | |
326 | Commander.tmux_wait_gen += 1 | |
327 | ||
328 | sudo_path = self.get_exec_path(["sudo"]) | |
329 | nscmd = sudo_path + " " + self.pre_cmd_str + cmd | |
330 | if "TMUX" in os.environ and not forcex: | |
331 | cmd = [self.get_exec_path("tmux")] | |
332 | if new_window: | |
333 | cmd.append("new-window") | |
334 | cmd.append("-P") | |
335 | if name: | |
336 | cmd.append("-n") | |
337 | cmd.append(name) | |
338 | if tmux_target: | |
339 | cmd.append("-t") | |
340 | cmd.append(tmux_target) | |
341 | else: | |
342 | cmd.append("split-window") | |
343 | cmd.append("-P") | |
344 | cmd.append("-h") | |
345 | if not tmux_target: | |
346 | tmux_target = os.getenv("TMUX_PANE", "") | |
347 | if background: | |
348 | cmd.append("-d") | |
349 | if tmux_target: | |
350 | cmd.append("-t") | |
351 | cmd.append(tmux_target) | |
352 | if title: | |
353 | nscmd = "printf '\033]2;{}\033\\'; {}".format(title, nscmd) | |
354 | if channel: | |
355 | nscmd = 'trap "tmux wait -S {}; exit 0" EXIT; {}'.format(channel, nscmd) | |
356 | cmd.append(nscmd) | |
357 | elif "STY" in os.environ and not forcex: | |
358 | # wait for not supported in screen for now | |
359 | channel = None | |
360 | cmd = [self.get_exec_path("screen")] | |
0bc76852 LS |
361 | if title: |
362 | cmd.append("-t") | |
363 | cmd.append(title) | |
6a5433ef CH |
364 | if not os.path.exists( |
365 | "/run/screen/S-{}/{}".format(os.environ["USER"], os.environ["STY"]) | |
366 | ): | |
367 | cmd = ["sudo", "-u", os.environ["SUDO_USER"]] + cmd | |
ce530b41 | 368 | cmd.extend(nscmd.split(" ")) |
6a5433ef CH |
369 | elif "DISPLAY" in os.environ: |
370 | # We need it broken up for xterm | |
371 | user_cmd = cmd | |
372 | cmd = [self.get_exec_path("xterm")] | |
373 | if "SUDO_USER" in os.environ: | |
374 | cmd = [self.get_exec_path("sudo"), "-u", os.environ["SUDO_USER"]] + cmd | |
1726edc3 CH |
375 | if title: |
376 | cmd.append("-T") | |
377 | cmd.append(title) | |
6a5433ef CH |
378 | cmd.append("-e") |
379 | cmd.append(sudo_path) | |
380 | cmd.extend(self.pre_cmd) | |
1726edc3 | 381 | cmd.extend(["bash", "-c", user_cmd]) |
6a5433ef CH |
382 | # if channel: |
383 | # return self.cmd_raises(cmd, skip_pre_cmd=True) | |
384 | # else: | |
385 | p = self.popen( | |
386 | cmd, | |
387 | skip_pre_cmd=True, | |
388 | stdin=None, | |
389 | shell=False, | |
6a5433ef CH |
390 | ) |
391 | time_mod.sleep(2) | |
392 | if p.poll() is not None: | |
393 | self.logger.error("%s: Failed to launch xterm: %s", self, comm_error(p)) | |
1726edc3 | 394 | return p |
6a5433ef CH |
395 | else: |
396 | self.logger.error( | |
397 | "DISPLAY, STY, and TMUX not in environment, can't open window" | |
398 | ) | |
399 | raise Exception("Window requestd but TMUX, Screen and X11 not available") | |
400 | ||
401 | pane_info = self.cmd_raises(cmd, skip_pre_cmd=True).strip() | |
402 | ||
403 | # Re-adjust the layout | |
404 | if "TMUX" in os.environ: | |
405 | self.cmd_status( | |
406 | "tmux select-layout -t {} tiled".format( | |
407 | pane_info if not tmux_target else tmux_target | |
408 | ), | |
409 | skip_pre_cmd=True, | |
410 | ) | |
411 | ||
412 | # Wait here if we weren't handed the channel to wait for | |
413 | if channel and wait_for is True: | |
414 | cmd = [self.get_exec_path("tmux"), "wait", channel] | |
415 | self.cmd_status(cmd, skip_pre_cmd=True) | |
416 | ||
417 | return pane_info | |
418 | ||
419 | def delete(self): | |
420 | pass | |
421 | ||
422 | ||
423 | class LinuxNamespace(Commander): | |
424 | """ | |
425 | A linux Namespace. | |
426 | ||
427 | An object that creates and executes commands in a linux namespace | |
428 | """ | |
429 | ||
430 | def __init__( | |
431 | self, | |
432 | name, | |
433 | net=True, | |
434 | mount=True, | |
435 | uts=True, | |
436 | cgroup=False, | |
437 | ipc=False, | |
438 | pid=False, | |
439 | time=False, | |
440 | user=False, | |
441 | set_hostname=True, | |
442 | private_mounts=None, | |
443 | logger=None, | |
444 | ): | |
445 | """ | |
446 | Create a new linux namespace. | |
447 | ||
448 | Args: | |
449 | name: Internal name for the namespace. | |
450 | net: Create network namespace. | |
451 | mount: Create network namespace. | |
452 | uts: Create UTS (hostname) namespace. | |
453 | cgroup: Create cgroup namespace. | |
454 | ipc: Create IPC namespace. | |
455 | pid: Create PID namespace, also mounts new /proc. | |
456 | time: Create time namespace. | |
457 | user: Create user namespace, also keeps capabilities. | |
458 | set_hostname: Set the hostname to `name`, uts must also be True. | |
459 | private_mounts: List of strings of the form | |
460 | "[/external/path:]/internal/path. If no external path is specified a | |
461 | tmpfs is mounted on the internal path. Any paths specified are first | |
462 | passed to `mkdir -p`. | |
463 | logger: Passed to superclass. | |
464 | """ | |
465 | super(LinuxNamespace, self).__init__(name, logger) | |
466 | ||
467 | self.logger.debug("%s: Creating", self) | |
468 | ||
469 | self.intfs = [] | |
470 | ||
471 | nslist = [] | |
472 | cmd = ["/usr/bin/unshare"] | |
473 | flags = "-" | |
474 | self.ifnetns = {} | |
475 | ||
476 | if cgroup: | |
477 | nslist.append("cgroup") | |
478 | flags += "C" | |
479 | if ipc: | |
480 | nslist.append("ipc") | |
481 | flags += "i" | |
482 | if mount: | |
483 | nslist.append("mnt") | |
484 | flags += "m" | |
485 | if net: | |
486 | nslist.append("net") | |
487 | flags += "n" | |
488 | if pid: | |
489 | nslist.append("pid") | |
490 | flags += "p" | |
491 | cmd.append("--mount-proc") | |
492 | if time: | |
493 | # XXX this filename is probably wrong | |
494 | nslist.append("time") | |
495 | flags += "T" | |
496 | if user: | |
497 | nslist.append("user") | |
498 | flags += "U" | |
499 | cmd.append("--keep-caps") | |
500 | if uts: | |
501 | nslist.append("uts") | |
502 | cmd.append("--uts") | |
503 | ||
504 | cmd.append(flags) | |
505 | cmd.append("/bin/cat") | |
506 | ||
507 | # Using cat and a stdin PIPE is nice as it will exit when we do. However, we | |
508 | # also detach it from the pgid so that signals do not propagate to it. This is | |
509 | # b/c it would exit early (e.g., ^C) then, at least the main micronet proc which | |
510 | # has no other processes like frr daemons running, will take the main network | |
511 | # namespace with it, which will remove the bridges and the veth pair (because | |
512 | # the bridge side veth is deleted). | |
513 | self.logger.debug("%s: creating namespace process: %s", self, cmd) | |
514 | p = subprocess.Popen( | |
515 | cmd, | |
516 | stdin=subprocess.PIPE, | |
517 | stdout=open("/dev/null", "w"), | |
518 | stderr=open("/dev/null", "w"), | |
519 | preexec_fn=os.setsid, # detach from pgid so signals don't propogate | |
520 | shell=False, | |
521 | ) | |
522 | self.p = p | |
523 | self.pid = p.pid | |
524 | ||
525 | self.logger.debug("%s: namespace pid: %d", self, self.pid) | |
526 | ||
527 | # ----------------------------------------------- | |
528 | # Now let's wait until unshare completes it's job | |
529 | # ----------------------------------------------- | |
530 | timeout = Timeout(30) | |
531 | while p.poll() is None and not timeout.is_expired(): | |
532 | for fname in tuple(nslist): | |
533 | ours = os.readlink("/proc/self/ns/{}".format(fname)) | |
534 | theirs = os.readlink("/proc/{}/ns/{}".format(self.pid, fname)) | |
535 | # See if their namespace is different | |
536 | if ours != theirs: | |
537 | nslist.remove(fname) | |
538 | if not nslist: | |
539 | break | |
540 | elapsed = int(timeout.elapsed()) | |
541 | if elapsed <= 3: | |
542 | time_mod.sleep(0.1) | |
543 | elif elapsed > 10: | |
544 | self.logger.warning("%s: unshare taking more than %ss", self, elapsed) | |
545 | time_mod.sleep(3) | |
546 | else: | |
547 | self.logger.info("%s: unshare taking more than %ss", self, elapsed) | |
548 | time_mod.sleep(1) | |
549 | assert p.poll() is None, "unshare unexpectedly exited!" | |
550 | assert not nslist, "unshare never unshared!" | |
551 | ||
552 | # Set pre-command based on our namespace proc | |
553 | self.base_pre_cmd = ["/usr/bin/nsenter", "-a", "-t", str(self.pid)] | |
554 | if not pid: | |
555 | self.base_pre_cmd.append("-F") | |
556 | self.set_pre_cmd(self.base_pre_cmd + ["--wd=" + self.cwd]) | |
557 | ||
49d72f2d | 558 | # Remount sysfs and cgroup to pickup any changes |
6a5433ef | 559 | self.cmd_raises("mount -t sysfs sysfs /sys") |
49d72f2d LS |
560 | self.cmd_raises( |
561 | "mount -o rw,nosuid,nodev,noexec,relatime -t cgroup2 cgroup /sys/fs/cgroup" | |
562 | ) | |
6a5433ef CH |
563 | |
564 | # Set the hostname to the namespace name | |
565 | if uts and set_hostname: | |
566 | # Debugging get the root hostname | |
567 | self.cmd_raises("hostname " + self.name) | |
568 | nroot = subprocess.check_output("hostname") | |
569 | if root_hostname != nroot: | |
570 | result = self.p.poll() | |
571 | assert root_hostname == nroot, "STATE of namespace process {}".format( | |
572 | result | |
573 | ) | |
574 | ||
575 | if private_mounts: | |
576 | if is_string(private_mounts): | |
577 | private_mounts = [private_mounts] | |
578 | for m in private_mounts: | |
579 | s = m.split(":", 1) | |
580 | if len(s) == 1: | |
581 | self.tmpfs_mount(s[0]) | |
582 | else: | |
583 | self.bind_mount(s[0], s[1]) | |
584 | ||
585 | o = self.cmd_legacy("ls -l /proc/{}/ns".format(self.pid)) | |
586 | self.logger.debug("namespaces:\n %s", o) | |
587 | ||
588 | # Doing this here messes up all_protocols ipv6 check | |
589 | self.cmd_raises("ip link set lo up") | |
590 | ||
591 | def __str__(self): | |
592 | return "LinuxNamespace({})".format(self.name) | |
593 | ||
594 | def tmpfs_mount(self, inner): | |
595 | self.cmd_raises("mkdir -p " + inner) | |
596 | self.cmd_raises("mount -n -t tmpfs tmpfs " + inner) | |
597 | ||
598 | def bind_mount(self, outer, inner): | |
599 | self.cmd_raises("mkdir -p " + inner) | |
600 | self.cmd_raises("mount --rbind {} {} ".format(outer, inner)) | |
601 | ||
97413ed7 PG |
602 | def add_vlan(self, vlanname, linkiface, vlanid): |
603 | self.logger.debug("Adding VLAN interface: %s (%s)", vlanname, vlanid) | |
604 | ip_path = self.get_exec_path("ip") | |
605 | assert ip_path, "XXX missing ip command!" | |
606 | self.cmd_raises( | |
607 | [ | |
608 | ip_path, | |
609 | "link", | |
610 | "add", | |
611 | "link", | |
612 | linkiface, | |
613 | "name", | |
614 | vlanname, | |
615 | "type", | |
616 | "vlan", | |
617 | "id", | |
618 | vlanid, | |
619 | ] | |
620 | ) | |
621 | self.cmd_raises([ip_path, "link", "set", "dev", vlanname, "up"]) | |
622 | ||
623 | def add_loop(self, loopname): | |
624 | self.logger.debug("Adding Linux iface: %s", loopname) | |
625 | ip_path = self.get_exec_path("ip") | |
626 | assert ip_path, "XXX missing ip command!" | |
627 | self.cmd_raises([ip_path, "link", "add", loopname, "type", "dummy"]) | |
628 | self.cmd_raises([ip_path, "link", "set", "dev", loopname, "up"]) | |
629 | ||
630 | def add_l3vrf(self, vrfname, tableid): | |
631 | self.logger.debug("Adding Linux VRF: %s", vrfname) | |
632 | ip_path = self.get_exec_path("ip") | |
633 | assert ip_path, "XXX missing ip command!" | |
634 | self.cmd_raises( | |
635 | [ip_path, "link", "add", vrfname, "type", "vrf", "table", tableid] | |
636 | ) | |
637 | self.cmd_raises([ip_path, "link", "set", "dev", vrfname, "up"]) | |
638 | ||
639 | def del_iface(self, iface): | |
640 | self.logger.debug("Removing Linux Iface: %s", iface) | |
641 | ip_path = self.get_exec_path("ip") | |
642 | assert ip_path, "XXX missing ip command!" | |
643 | self.cmd_raises([ip_path, "link", "del", iface]) | |
644 | ||
645 | def attach_iface_to_l3vrf(self, ifacename, vrfname): | |
646 | self.logger.debug("Attaching Iface %s to Linux VRF %s", ifacename, vrfname) | |
647 | ip_path = self.get_exec_path("ip") | |
648 | assert ip_path, "XXX missing ip command!" | |
649 | if vrfname: | |
650 | self.cmd_raises( | |
651 | [ip_path, "link", "set", "dev", ifacename, "master", vrfname] | |
652 | ) | |
653 | else: | |
654 | self.cmd_raises([ip_path, "link", "set", "dev", ifacename, "nomaster"]) | |
655 | ||
6a5433ef CH |
656 | def add_netns(self, ns): |
657 | self.logger.debug("Adding network namespace %s", ns) | |
658 | ||
659 | ip_path = self.get_exec_path("ip") | |
660 | assert ip_path, "XXX missing ip command!" | |
661 | if os.path.exists("/run/netns/{}".format(ns)): | |
662 | self.logger.warning("%s: Removing existing nsspace %s", self, ns) | |
663 | try: | |
664 | self.delete_netns(ns) | |
665 | except Exception as ex: | |
666 | self.logger.warning( | |
667 | "%s: Couldn't remove existing nsspace %s: %s", | |
668 | self, | |
669 | ns, | |
670 | str(ex), | |
671 | exc_info=True, | |
672 | ) | |
673 | self.cmd_raises([ip_path, "netns", "add", ns]) | |
674 | ||
675 | def delete_netns(self, ns): | |
676 | self.logger.debug("Deleting network namespace %s", ns) | |
677 | ||
678 | ip_path = self.get_exec_path("ip") | |
679 | assert ip_path, "XXX missing ip command!" | |
680 | self.cmd_raises([ip_path, "netns", "delete", ns]) | |
681 | ||
682 | def set_intf_netns(self, intf, ns, up=False): | |
683 | # In case a user hard-codes 1 thinking it "resets" | |
684 | ns = str(ns) | |
685 | if ns == "1": | |
686 | ns = str(self.pid) | |
687 | ||
688 | self.logger.debug("Moving interface %s to namespace %s", intf, ns) | |
689 | ||
690 | cmd = "ip link set {} netns " + ns | |
691 | if up: | |
692 | cmd += " up" | |
693 | self.intf_ip_cmd(intf, cmd) | |
694 | if ns == str(self.pid): | |
695 | # If we are returning then remove from dict | |
696 | if intf in self.ifnetns: | |
697 | del self.ifnetns[intf] | |
698 | else: | |
699 | self.ifnetns[intf] = ns | |
700 | ||
701 | def reset_intf_netns(self, intf): | |
702 | self.logger.debug("Moving interface %s to default namespace", intf) | |
703 | self.set_intf_netns(intf, str(self.pid)) | |
704 | ||
705 | def intf_ip_cmd(self, intf, cmd): | |
706 | """Run an ip command for considering an interfaces possible namespace. | |
707 | ||
708 | `cmd` - format is run using the interface name on the command | |
709 | """ | |
710 | if intf in self.ifnetns: | |
711 | assert cmd.startswith("ip ") | |
712 | cmd = "ip -n " + self.ifnetns[intf] + cmd[2:] | |
713 | self.cmd_raises(cmd.format(intf)) | |
714 | ||
715 | def set_cwd(self, cwd): | |
716 | # Set pre-command based on our namespace proc | |
717 | self.logger.debug("%s: new CWD %s", self, cwd) | |
718 | self.set_pre_cmd(self.base_pre_cmd + ["--wd=" + cwd]) | |
719 | ||
720 | def register_interface(self, ifname): | |
721 | if ifname not in self.intfs: | |
722 | self.intfs.append(ifname) | |
723 | ||
724 | def delete(self): | |
725 | if self.p and self.p.poll() is None: | |
726 | if sys.version_info[0] >= 3: | |
727 | try: | |
728 | self.p.terminate() | |
729 | self.p.communicate(timeout=10) | |
730 | except subprocess.TimeoutExpired: | |
731 | self.p.kill() | |
732 | self.p.communicate(timeout=2) | |
733 | else: | |
734 | self.p.kill() | |
735 | self.p.communicate() | |
736 | self.set_pre_cmd(["/bin/false"]) | |
737 | ||
738 | ||
739 | class SharedNamespace(Commander): | |
740 | """ | |
741 | Share another namespace. | |
742 | ||
743 | An object that executes commands in an existing pid's linux namespace | |
744 | """ | |
745 | ||
746 | def __init__(self, name, pid, logger=None): | |
747 | """ | |
748 | Share a linux namespace. | |
749 | ||
750 | Args: | |
751 | name: Internal name for the namespace. | |
752 | pid: PID of the process to share with. | |
753 | """ | |
754 | super(SharedNamespace, self).__init__(name, logger) | |
755 | ||
756 | self.logger.debug("%s: Creating", self) | |
757 | ||
758 | self.pid = pid | |
759 | self.intfs = [] | |
760 | ||
761 | # Set pre-command based on our namespace proc | |
762 | self.set_pre_cmd( | |
763 | ["/usr/bin/nsenter", "-a", "-t", str(self.pid), "--wd=" + self.cwd] | |
764 | ) | |
765 | ||
766 | def __str__(self): | |
767 | return "SharedNamespace({})".format(self.name) | |
768 | ||
769 | def set_cwd(self, cwd): | |
770 | # Set pre-command based on our namespace proc | |
771 | self.logger.debug("%s: new CWD %s", self, cwd) | |
772 | self.set_pre_cmd(["/usr/bin/nsenter", "-a", "-t", str(self.pid), "--wd=" + cwd]) | |
773 | ||
774 | def register_interface(self, ifname): | |
775 | if ifname not in self.intfs: | |
776 | self.intfs.append(ifname) | |
777 | ||
778 | ||
779 | class Bridge(SharedNamespace): | |
780 | """ | |
781 | A linux bridge. | |
782 | """ | |
783 | ||
784 | next_brid_ord = 0 | |
785 | ||
786 | @classmethod | |
787 | def _get_next_brid(cls): | |
788 | brid_ord = cls.next_brid_ord | |
789 | cls.next_brid_ord += 1 | |
790 | return brid_ord | |
791 | ||
792 | def __init__(self, name=None, unet=None, logger=None): | |
793 | """Create a linux Bridge.""" | |
794 | ||
795 | self.unet = unet | |
796 | self.brid_ord = self._get_next_brid() | |
797 | if name: | |
798 | self.brid = name | |
799 | else: | |
800 | self.brid = "br{}".format(self.brid_ord) | |
801 | name = self.brid | |
802 | ||
803 | super(Bridge, self).__init__(name, unet.pid, logger) | |
804 | ||
805 | self.logger.debug("Bridge: Creating") | |
806 | ||
807 | assert len(self.brid) <= 16 # Make sure fits in IFNAMSIZE | |
808 | self.cmd_raises("ip link delete {} || true".format(self.brid)) | |
809 | self.cmd_raises("ip link add {} type bridge".format(self.brid)) | |
810 | self.cmd_raises("ip link set {} up".format(self.brid)) | |
811 | ||
812 | self.logger.debug("%s: Created, Running", self) | |
813 | ||
814 | def __str__(self): | |
815 | return "Bridge({})".format(self.brid) | |
816 | ||
817 | def delete(self): | |
818 | """Stop the bridge (i.e., delete the linux resources).""" | |
819 | ||
820 | rc, o, e = self.cmd_status("ip link show {}".format(self.brid), warn=False) | |
821 | if not rc: | |
822 | rc, o, e = self.cmd_status( | |
823 | "ip link delete {}".format(self.brid), warn=False | |
824 | ) | |
825 | if rc: | |
826 | self.logger.error( | |
827 | "%s: error deleting bridge %s: %s", | |
828 | self, | |
829 | self.brid, | |
830 | cmd_error(rc, o, e), | |
831 | ) | |
832 | else: | |
833 | self.logger.debug("%s: Deleted.", self) | |
834 | ||
835 | ||
836 | class Micronet(LinuxNamespace): # pylint: disable=R0205 | |
837 | """ | |
838 | Micronet. | |
839 | """ | |
840 | ||
841 | def __init__(self): | |
842 | """Create a Micronet.""" | |
843 | ||
844 | self.hosts = {} | |
845 | self.switches = {} | |
846 | self.links = {} | |
847 | self.macs = {} | |
848 | self.rmacs = {} | |
849 | ||
850 | super(Micronet, self).__init__("micronet", mount=True, net=True, uts=True) | |
851 | ||
852 | self.logger.debug("%s: Creating", self) | |
853 | ||
854 | def __str__(self): | |
855 | return "Micronet()" | |
856 | ||
857 | def __getitem__(self, key): | |
858 | if key in self.switches: | |
859 | return self.switches[key] | |
860 | return self.hosts[key] | |
861 | ||
862 | def add_host(self, name, cls=LinuxNamespace, **kwargs): | |
863 | """Add a host to micronet.""" | |
864 | ||
865 | self.logger.debug("%s: add_host %s", self, name) | |
866 | ||
867 | self.hosts[name] = cls(name, **kwargs) | |
868 | # Create a new mounted FS for tracking nested network namespaces creatd by the | |
869 | # user with `ip netns add` | |
870 | self.hosts[name].tmpfs_mount("/run/netns") | |
871 | ||
872 | def add_link(self, name1, name2, if1, if2): | |
873 | """Add a link between switch and host to micronet.""" | |
874 | isp2p = False | |
875 | if name1 in self.switches: | |
876 | assert name2 in self.hosts | |
877 | elif name2 in self.switches: | |
878 | assert name1 in self.hosts | |
879 | name1, name2 = name2, name1 | |
880 | if1, if2 = if2, if1 | |
881 | else: | |
882 | # p2p link | |
883 | assert name1 in self.hosts | |
884 | assert name2 in self.hosts | |
885 | isp2p = True | |
886 | ||
887 | lname = "{}:{}-{}:{}".format(name1, if1, name2, if2) | |
888 | self.logger.debug("%s: add_link %s%s", self, lname, " p2p" if isp2p else "") | |
889 | self.links[lname] = (name1, if1, name2, if2) | |
890 | ||
891 | # And create the veth now. | |
892 | if isp2p: | |
893 | lhost, rhost = self.hosts[name1], self.hosts[name2] | |
894 | lifname = "i1{:x}".format(lhost.pid) | |
895 | rifname = "i2{:x}".format(rhost.pid) | |
896 | self.cmd_raises( | |
897 | "ip link add {} type veth peer name {}".format(lifname, rifname) | |
898 | ) | |
899 | ||
900 | self.cmd_raises("ip link set {} netns {}".format(lifname, lhost.pid)) | |
901 | lhost.cmd_raises("ip link set {} name {}".format(lifname, if1)) | |
902 | lhost.cmd_raises("ip link set {} up".format(if1)) | |
903 | lhost.register_interface(if1) | |
904 | ||
905 | self.cmd_raises("ip link set {} netns {}".format(rifname, rhost.pid)) | |
906 | rhost.cmd_raises("ip link set {} name {}".format(rifname, if2)) | |
907 | rhost.cmd_raises("ip link set {} up".format(if2)) | |
908 | rhost.register_interface(if2) | |
909 | else: | |
910 | switch = self.switches[name1] | |
911 | host = self.hosts[name2] | |
912 | ||
913 | assert len(if1) <= 16 and len(if2) <= 16 # Make sure fits in IFNAMSIZE | |
914 | ||
915 | self.logger.debug("%s: Creating veth pair for link %s", self, lname) | |
916 | self.cmd_raises( | |
917 | "ip link add {} type veth peer name {} netns {}".format( | |
918 | if1, if2, host.pid | |
919 | ) | |
920 | ) | |
921 | self.cmd_raises("ip link set {} netns {}".format(if1, switch.pid)) | |
922 | switch.register_interface(if1) | |
923 | host.register_interface(if2) | |
924 | self.cmd_raises("ip link set {} master {}".format(if1, switch.brid)) | |
925 | self.cmd_raises("ip link set {} up".format(if1)) | |
926 | host.cmd_raises("ip link set {} up".format(if2)) | |
927 | ||
928 | # Cache the MAC values, and reverse mapping | |
929 | self.get_mac(name1, if1) | |
930 | self.get_mac(name2, if2) | |
931 | ||
932 | def add_switch(self, name): | |
933 | """Add a switch to micronet.""" | |
934 | ||
935 | self.logger.debug("%s: add_switch %s", self, name) | |
936 | self.switches[name] = Bridge(name, self) | |
937 | ||
938 | def get_mac(self, name, ifname): | |
939 | if name in self.hosts: | |
940 | dev = self.hosts[name] | |
941 | else: | |
942 | dev = self.switches[name] | |
943 | ||
944 | if (name, ifname) not in self.macs: | |
945 | _, output, _ = dev.cmd_status("ip -o link show " + ifname) | |
946 | m = re.match(".*link/(loopback|ether) ([0-9a-fA-F:]+) .*", output) | |
947 | mac = m.group(2) | |
948 | self.macs[(name, ifname)] = mac | |
949 | self.rmacs[mac] = (name, ifname) | |
950 | ||
951 | return self.macs[(name, ifname)] | |
952 | ||
953 | def delete(self): | |
954 | """Delete the micronet topology.""" | |
955 | ||
956 | self.logger.debug("%s: Deleting.", self) | |
957 | ||
958 | for lname, (_, _, rname, rif) in self.links.items(): | |
959 | host = self.hosts[rname] | |
960 | ||
961 | self.logger.debug("%s: Deleting veth pair for link %s", self, lname) | |
962 | ||
963 | rc, o, e = host.cmd_status("ip link delete {}".format(rif), warn=False) | |
964 | if rc: | |
965 | self.logger.error( | |
966 | "Error deleting veth pair %s: %s", lname, cmd_error(rc, o, e) | |
967 | ) | |
968 | ||
969 | self.links = {} | |
970 | ||
971 | for host in self.hosts.values(): | |
972 | try: | |
973 | host.delete() | |
974 | except Exception as error: | |
975 | self.logger.error( | |
976 | "%s: error while deleting host %s: %s", self, host, error | |
977 | ) | |
978 | ||
979 | self.hosts = {} | |
980 | ||
981 | for switch in self.switches.values(): | |
982 | try: | |
983 | switch.delete() | |
984 | except Exception as error: | |
985 | self.logger.error( | |
986 | "%s: error while deleting switch %s: %s", self, switch, error | |
987 | ) | |
988 | self.switches = {} | |
989 | ||
990 | self.logger.debug("%s: Deleted.", self) | |
991 | ||
992 | super(Micronet, self).delete() | |
993 | ||
994 | ||
995 | # --------------------------- | |
996 | # Root level utility function | |
997 | # --------------------------- | |
998 | ||
999 | ||
1000 | def get_exec_path(binary): | |
1001 | base = Commander("base") | |
1002 | return base.get_exec_path(binary) | |
1003 | ||
1004 | ||
1005 | commander = Commander("micronet") |