cosmetic
[nodemanager.git] / tools.py
1 # -*- python-indent: 4 -*-
2
3 """A few things that didn't seem to fit anywhere else."""
4
5 import os
6 import os.path
7 import pwd
8 import tempfile
9 import fcntl
10 import errno
11 import threading
12 import subprocess
13 import shutil
14 import sys
15 import signal
16
17 import logger
18
19 PID_FILE = '/var/run/nodemanager.pid'
20
21 ####################
22
23
24 def get_default_if():
25     interface = get_if_from_hwaddr(get_hwaddr_from_plnode())
26     if not interface:
27         interface = "eth0"
28     return interface
29
30
31 def get_hwaddr_from_plnode():
32     try:
33         for line in open("/usr/boot/plnode.txt", 'r').readlines():
34             if line.startswith("NET_DEVICE"):
35                 return line.split("=")[1].strip().strip('"')
36     except:
37         pass
38     return None
39
40
41 def get_if_from_hwaddr(hwaddr):
42     import sioc
43     devs = sioc.gifconf()
44     for dev in devs:
45         dev_hwaddr = sioc.gifhwaddr(dev)
46         if dev_hwaddr == hwaddr:
47             return dev
48     return None
49
50 ####################
51 # daemonizing
52
53
54 def as_daemon_thread(run):
55     """Call function <run> with no arguments in its own thread."""
56     thr = threading.Thread(target=run)
57     thr.setDaemon(True)
58     thr.start()
59
60
61 def close_nonstandard_fds():
62     """Close all open file descriptors other than 0, 1, and 2."""
63     _SC_OPEN_MAX = 4
64     for fd in range(3, os.sysconf(_SC_OPEN_MAX)):
65         try:
66             os.close(fd)
67         except OSError:
68             pass  # most likely an fd that isn't open
69
70 # after http://www.erlenstar.demon.co.uk/unix/faq_2.html
71
72
73 def daemon():
74     """Daemonize the current process."""
75     if os.fork() != 0:
76         os._exit(0)
77     os.setsid()
78     if os.fork() != 0:
79         os._exit(0)
80     os.chdir('/')
81     os.umask(0o022)
82     devnull = os.open(os.devnull, os.O_RDWR)
83     os.dup2(devnull, 0)
84     # xxx fixme - this is just to make sure that nothing gets stupidly lost - should use devnull
85     crashlog = os.open('/var/log/nodemanager.daemon',
86                        os.O_RDWR | os.O_APPEND | os.O_CREAT, 0o644)
87     os.dup2(crashlog, 1)
88     os.dup2(crashlog, 2)
89
90
91 def fork_as(su, function, *args):
92     """
93 fork(), cd / to avoid keeping unused directories open,
94 close all nonstandard file descriptors (to avoid capturing open sockets),
95 fork() again (to avoid zombies) and call <function>
96 with arguments <args> in the grandchild process.
97 If <su> is not None, set our group and user ids
98  appropriately in the child process.
99     """
100     child_pid = os.fork()
101     if child_pid == 0:
102         try:
103             os.chdir('/')
104             close_nonstandard_fds()
105             if su:
106                 pw_ent = pwd.getpwnam(su)
107                 os.setegid(pw_ent[3])
108                 os.seteuid(pw_ent[2])
109             child_pid = os.fork()
110             if child_pid == 0:
111                 function(*args)
112         except:
113             os.seteuid(os.getuid())  # undo su so we can write the log file
114             os.setegid(os.getgid())
115             logger.log_exc("tools: fork_as")
116         os._exit(0)
117     else:
118         os.waitpid(child_pid, 0)
119
120 ####################
121 # manage files
122
123
124 def pid_file():
125     """
126 We use a pid file to ensure that only one copy of NM is running at a given time.
127 If successful, this function will write a pid file containing the pid of the current process.
128 The return value is the pid of the other running process, or None otherwise.
129     """
130     other_pid = None
131     if os.access(PID_FILE, os.F_OK):  # check for a pid file
132         handle = open(PID_FILE)  # pid file exists, read it
133         other_pid = int(handle.read())
134         handle.close()
135         # check for a process with that pid by sending signal 0
136         try:
137             os.kill(other_pid, 0)
138         except OSError as e:
139             if e.errno == errno.ESRCH:
140                 other_pid = None  # doesn't exist
141             else:
142                 raise  # who knows
143     if other_pid == None:
144         # write a new pid file
145         write_file(PID_FILE, lambda f: f.write(str(os.getpid())))
146     return other_pid
147
148
149 def write_file(filename, do_write, **kw_args):
150     """
151 Write file <filename> atomically by opening a temporary file,
152 using <do_write> to write that file, and then renaming the temporary file.
153     """
154     shutil.move(write_temp_file(do_write, **kw_args), filename)
155
156
157 def write_temp_file(do_write, mode=None, uidgid=None):
158     fd, temporary_filename = tempfile.mkstemp()
159     if mode:
160         os.chmod(temporary_filename, mode)
161     if uidgid:
162         os.chown(temporary_filename, *uidgid)
163     f = os.fdopen(fd, 'w')
164     try:
165         do_write(f)
166     finally:
167         f.close()
168     return temporary_filename
169
170
171 def replace_file_with_string(target, new_contents,
172                              chmod=None, remove_if_empty=False):
173     """
174 Replace a target file with a new contents
175 checks for changes: does not do anything if previous state was already right
176 can handle chmod if requested
177 can also remove resulting file if contents are void, if requested
178 performs atomically:
179 writes in a tmp file, which is then renamed (from sliverauth originally)
180 returns True if a change occurred, or the file is deleted
181     """
182     try:
183         with open(target) as feed:
184             current = feed.read()
185     except:
186         current = ""
187     if current == new_contents:
188         # if turns out to be an empty string, and remove_if_empty is set,
189         # then make sure to trash the file if it exists
190         if remove_if_empty and not new_contents and os.path.isfile(target):
191             logger.verbose(
192                 "tools.replace_file_with_string: removing file {}".format(target))
193             try:
194                 os.unlink(target)
195             finally:
196                 return True
197         return False
198     # overwrite target file: create a temp in the same directory
199     path = os.path.dirname(target) or '.'
200     fd, name = tempfile.mkstemp('', 'repl', path)
201     os.write(fd, new_contents.encode())
202     os.close(fd)
203     if os.path.exists(target):
204         os.unlink(target)
205     shutil.move(name, target)
206     if chmod:
207         os.chmod(target, chmod)
208     return True
209
210 ####################
211 # utilities functions to get (cached) information from the node
212
213
214 # get node_id from /etc/planetlab/node_id and cache it
215 _node_id = None
216
217
218 def node_id():
219     global _node_id
220     if _node_id is None:
221         try:
222             with open("/etc/planetlab/node_id") as f:
223                 _node_id = int(f.read())
224         except:
225             _node_id = ""
226     return _node_id
227
228
229 _root_context_arch = None
230
231
232 def root_context_arch():
233     global _root_context_arch
234     if not _root_context_arch:
235         sp = subprocess.Popen(["uname", "-i"], stdout=subprocess.PIPE)
236         (_root_context_arch, _) = sp.communicate()
237         _root_context_arch = _root_context_arch.strip()
238     return _root_context_arch
239
240
241 ####################
242 class NMLock:
243     def __init__(self, file):
244         logger.log("tools: Lock {} initialized.".format(file), 2)
245         self.fd = os.open(file, os.O_RDWR | os.O_CREAT, 0o600)
246         flags = fcntl.fcntl(self.fd, fcntl.F_GETFD)
247         flags |= fcntl.FD_CLOEXEC
248         fcntl.fcntl(self.fd, fcntl.F_SETFD, flags)
249
250     def __del__(self):
251         os.close(self.fd)
252
253     def acquire(self):
254         logger.log("tools: Lock acquired.", 2)
255         fcntl.lockf(self.fd, fcntl.LOCK_SH)
256
257     def release(self):
258         logger.log("tools: Lock released.", 2)
259         fcntl.lockf(self.fd, fcntl.LOCK_UN)
260
261 ####################
262 # Utilities for getting the IP address of a LXC/Openvswitch slice. Do this by
263 # running ifconfig inside of the slice's context.
264
265
266 def get_sliver_process(slice_name, process_cmdline):
267     """
268     Utility function to find a process inside of an LXC sliver. Returns
269     (cgroup_fn, pid). cgroup_fn is the filename of the cgroup file for
270     the process, for example /proc/2592/cgroup. Pid is the process id of
271     the process. If the process is not found then (None, None) is returned.
272     """
273     try:
274         cmd = 'grep {} /proc/*/cgroup | grep freezer'.format(slice_name)
275         output = os.popen(cmd).readlines()
276     except:
277         # the slice couldn't be found
278         logger.log(
279             "get_sliver_process: couldn't find slice {}".format(slice_name))
280         return (None, None)
281
282     cgroup_fn = None
283     pid = None
284     for e in output:
285         try:
286             l = e.rstrip()
287             path = l.split(':')[0]
288             comp = l.rsplit(':')[-1]
289             slice_name_check = comp.rsplit('/')[-1]
290             # the lines below were added by Guilherme <gsm@machados.org>
291             # due to the LXC requirements
292             # What we have to consider here is that libervirt on Fedora 18
293             # uses the following line:
294             # /proc/1253/cgroup:6:freezer:/machine.slice/auto_sirius.libvirt-lxc
295             # While the libvirt on Fedora 20 and 21 uses the following line:
296             # /proc/1253/cgroup:6:freezer:/machine.slice/machine-lxc\x2del_sirius.scope
297             # Further documentation on:
298             # https://libvirt.org/cgroups.html#systemdScope
299             virt = get_node_virt()
300             if virt == 'lxc':
301                 # This is for Fedora 20 or later
302                 regexf20orlater = re.compile(r'machine-lxc\\x2d(.+).scope')
303                 isf20orlater = regexf20orlater.search(slice_name_check)
304                 if isf20orlater:
305                     slice_name_check = isf20orlater.group(1)
306                 else:
307                     # This is for Fedora 18
308                     slice_name_check = slice_name_check.rsplit('.')[0]
309
310             if (slice_name_check == slice_name):
311                 slice_path = path
312                 pid = slice_path.split('/')[2]
313                 with open('/proc/{}/cmdline'.format(pid)) as cmdfile:
314                     cmdline = cmdfile.read().rstrip('\n\x00')
315                 if (cmdline == process_cmdline):
316                     cgroup_fn = slice_path
317                     break
318         except:
319             break
320
321     if (not cgroup_fn) or (not pid):
322         logger.log("get_sliver_process: process {} not running in slice {}"
323                    .format(process_cmdline, slice_name))
324         return (None, None)
325
326     return (cgroup_fn, pid)
327
328 ###################################################
329 # Added by Guilherme Sperb Machado <gsm@machados.org>
330 ###################################################
331
332
333 try:
334     import re
335     import socket
336     import fileinput
337 except:
338     logger.log("Could not import 're', 'socket', or 'fileinput' python packages.")
339
340 # TODO: is there anything better to do if the "libvirt", "sliver_libvirt",
341 # and "sliver_lxc" are not in place?
342 try:
343     import libvirt
344     from sliver_libvirt import Sliver_Libvirt
345     import sliver_lxc
346 except:
347     logger.log("Could not import 'sliver_lxc' or 'libvirt' or 'sliver_libvirt'.")
348 ###################################################
349
350
351 def get_sliver_ifconfig(slice_name, device="eth0"):
352     """
353     return the output of "ifconfig" run from inside the sliver.
354
355     side effects: adds "/usr/sbin" to sys.path
356     """
357
358     # See if setns is installed. If it's not then we're probably not running
359     # LXC.
360     if not os.path.exists("/usr/sbin/setns.so"):
361         return None
362
363     # setns is part of lxcsu and is installed to /usr/sbin
364     if not "/usr/sbin" in sys.path:
365         sys.path.append("/usr/sbin")
366     import setns
367
368     (cgroup_fn, pid) = get_sliver_process(slice_name, "/sbin/init")
369     if (not cgroup_fn) or (not pid):
370         return None
371
372     path = '/proc/{}/ns/net'.format(pid)
373
374     result = None
375     try:
376         setns.chcontext(path)
377
378         args = ["/sbin/ifconfig", device]
379         sub = subprocess.Popen(
380             args, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
381         sub.wait()
382
383         if (sub.returncode != 0):
384             logger.log("get_slice_ifconfig: error in ifconfig: {}".format(
385                 sub.stderr.read()))
386
387         result = sub.stdout.read()
388     finally:
389         setns.chcontext("/proc/1/ns/net")
390
391     return result
392
393
394 def get_sliver_ip(slice_name):
395     ifconfig = get_sliver_ifconfig(slice_name)
396     if not ifconfig:
397         return None
398
399     for line in ifconfig.split("\n"):
400         if "inet addr:" in line:
401             # example: '          inet addr:192.168.122.189  Bcast:192.168.122.255  Mask:255.255.255.0'
402             parts = line.strip().split()
403             if len(parts) >= 2 and parts[1].startswith("addr:"):
404                 return parts[1].split(":")[1]
405
406     return None
407
408 ###################################################
409 # Author: Guilherme Sperb Machado <gsm@machados.org>
410 ###################################################
411 # Get the slice ipv6 address
412 # Only for LXC!
413 ###################################################
414
415
416 def get_sliver_ipv6(slice_name):
417     ifconfig = get_sliver_ifconfig(slice_name)
418     if not ifconfig:
419         return None, None
420
421     # example: 'inet6 2001:67c:16dc:1302:5054:ff:fea7:7882  prefixlen 64  scopeid 0x0<global>'
422     prog = re.compile(
423         r'inet6\s+(.*)\s+prefixlen\s+(\d+)\s+scopeid\s+(.+)<global>')
424     for line in ifconfig.split("\n"):
425         search = prog.search(line)
426         if search:
427             ipv6addr = search.group(1)
428             prefixlen = search.group(2)
429             return (ipv6addr, prefixlen)
430     return None, None
431
432 ###################################################
433 # Author: Guilherme Sperb Machado <gsm@machados.org>
434 ###################################################
435 # Check if the address is a AF_INET6 family address
436 ###################################################
437
438
439 def is_valid_ipv6(ipv6addr):
440     try:
441         socket.inet_pton(socket.AF_INET6, ipv6addr)
442     except socket.error:
443         return False
444     return True
445
446
447 # this returns the kind of virtualization on the node
448 # either 'vs' or 'lxc'
449 # also caches it in /etc/planetlab/virt for next calls
450 # could be promoted to core nm if need be
451 virt_stamp = "/etc/planetlab/virt"
452
453
454 def get_node_virt():
455     try:
456         with open(virt_stamp) as f:
457             return f.read().strip()
458     except:
459         pass
460     logger.log("Computing virt..")
461     try:
462         virt = 'vs' if subprocess.call(['vserver', '--help']) == 0 else 'lxc'
463     except:
464         virt = 'lxc'
465     with open(virt_stamp, "w") as f:
466         f.write(virt)
467     return virt
468
469
470 # this return True or False to indicate that systemctl is present on that box
471 # cache result in memory as _has_systemctl
472 _has_systemctl = None
473
474
475 def has_systemctl():
476     global _has_systemctl
477     if _has_systemctl is None:
478         _has_systemctl = (subprocess.call(['systemctl', '--help']) == 0)
479     return _has_systemctl
480
481 ###################################################
482 # Author: Guilherme Sperb Machado <gsm@machados.org>
483 ###################################################
484 # This method was developed to support the ipv6 plugin
485 # Only for LXC!
486 ###################################################
487
488
489 def reboot_slivers():
490     type = 'sliver.LXC'
491     # connecting to the libvirtd
492     connLibvirt = Sliver_Libvirt.getConnection(type)
493     domains = connLibvirt.listAllDomains()
494     for domain in domains:
495         try:
496             # set the flag VIR_DOMAIN_REBOOT_INITCTL, which uses "initctl"
497             result = domain.reboot(0x04)
498             if result == 0:
499                 logger.log("tools: REBOOT {}".format(domain.name()))
500             else:
501                 raise Exception()
502         except Exception as e:
503             logger.log("tools: FAILED to reboot {} ({})".format(
504                 domain.name(), e))
505             logger.log(
506                 "tools: Trying to DESTROY/CREATE {} instead...".format(domain.name()))
507             try:
508                 result = domain.destroy()
509                 if result == 0:
510                     logger.log("tools: DESTROYED {}".format(domain.name()))
511                 else:
512                     logger.log(
513                         "tools: FAILED in the DESTROY call of {}".format(domain.name()))
514                 result = domain.create()
515                 if result == 0:
516                     logger.log("tools: CREATED {}".format(domain.name()))
517                 else:
518                     logger.log(
519                         "tools: FAILED in the CREATE call of {}".format(domain.name()))
520             except Exception as e:
521                 logger.log(
522                     "tools: FAILED to DESTROY/CREATE {} ({})".format(domain.name(), e))
523
524 ###################################################
525 # Author: Guilherme Sperb Machado <gsm@machados.org>
526 ###################################################
527 # Get the /etc/hosts file path
528 ###################################################
529
530
531 def get_hosts_file_path(slicename):
532     containerDir = os.path.join(sliver_lxc.Sliver_LXC.CON_BASE_DIR, slicename)
533     return os.path.join(containerDir, 'etc', 'hosts')
534
535 ###################################################
536 # Author: Guilherme Sperb Machado <gsm@machados.org>
537 ###################################################
538 # Search if there is a specific ipv6 address in the
539 # /etc/hosts file of a given slice
540 # If the parameter 'ipv6addr' is None, then search
541 # for any ipv6 address
542 ###################################################
543
544
545 def search_ipv6addr_hosts(slicename, ipv6addr):
546     hostsFilePath = get_hosts_file_path(slicename)
547     found = False
548     try:
549         for line in fileinput.input(r'{}'.format(hostsFilePath)):
550             if ipv6addr is not None:
551                 if re.search(r'{}'.format(ipv6addr), line):
552                     found = True
553             else:
554                 search = re.search(r'^(.*)\s+.*$', line)
555                 if search:
556                     ipv6candidate = search.group(1)
557                     ipv6candidatestrip = ipv6candidate.strip()
558                     valid = is_valid_ipv6(ipv6candidatestrip)
559                     if valid:
560                         found = True
561         fileinput.close()
562         return found
563     except:
564         logger.log("tools: FAILED to search {} in /etc/hosts file of slice={}"
565                    .format(ipv6addr, slicename))
566
567 ###################################################
568 # Author: Guilherme Sperb Machado <gsm@machados.org>
569 ###################################################
570 # Removes all ipv6 addresses from the /etc/hosts
571 # file of a given slice
572 ###################################################
573
574
575 def remove_all_ipv6addr_hosts(slicename, node):
576     hostsFilePath = get_hosts_file_path(slicename)
577     try:
578         for line in fileinput.input(r'{}'.format(hostsFilePath), inplace=True):
579             search = re.search(
580                 r'^(.*)\s+({}|{})$'.format(node, 'localhost'), line)
581             if search:
582                 ipv6candidate = search.group(1)
583                 ipv6candidatestrip = ipv6candidate.strip()
584                 valid = is_valid_ipv6(ipv6candidatestrip)
585                 if not valid:
586                     print(line, end=' ')
587         fileinput.close()
588         logger.log("tools: REMOVED IPv6 address from /etc/hosts file of slice={}"
589                    .format(slicename))
590     except:
591         logger.log("tools: FAILED to remove the IPv6 address from /etc/hosts file of slice={}"
592                    .format(slicename))
593
594 ###################################################
595 # Author: Guilherme Sperb Machado <gsm@machados.org>
596 ###################################################
597 # Adds an ipv6 address to the /etc/hosts file within a slice
598 ###################################################
599
600
601 def add_ipv6addr_hosts_line(slicename, node, ipv6addr):
602     hostsFilePath = get_hosts_file_path(slicename)
603     logger.log("tools: {}".format(hostsFilePath))
604     # debugging purposes:
605     #string = "127.0.0.1\tlocalhost\n192.168.100.179\tmyplc-node1-vm.mgmt.local\n"
606     #string = "127.0.0.1\tlocalhost\n"
607     try:
608         with open(hostsFilePath, "a") as file:
609             file.write(ipv6addr + " " + node + "\n")
610             file.close()
611         logger.log("tools: ADDED IPv6 address to /etc/hosts file of slice={}"
612                    .format(slicename))
613     except:
614         logger.log("tools: FAILED to add the IPv6 address to /etc/hosts file of slice={}"
615                    .format(slicename))
616
617
618 # how to run a command in a slice
619 # now this is a painful matter
620 # the problem is with capsh that forces a bash command to be injected in its exec'ed command
621 # so because lxcsu uses capsh, you cannot exec anything else than bash
622 # bottom line is, what actually needs to be called is
623 # vs:  vserver exec slicename command and its arguments
624 # lxc: lxcsu slicename "command and its arguments"
625 # which, OK, is no big deal as long as the command is simple enough,
626 # but do not stretch it with arguments that have spaces or need quoting as that will become a nightmare
627 def command_in_slice(slicename, argv):
628     virt = get_node_virt()
629     if virt == 'vs':
630         return ['vserver', slicename, 'exec', ] + argv
631     elif virt == 'lxc':
632         # wrap up argv in a single string for -c
633         return ['lxcsu', slicename, ] + [" ".join(argv)]
634     logger.log("command_in_slice: WARNING: could not find a valid virt")
635     return argv
636
637 ####################
638
639
640 def init_signals():
641     def handler(signum, frame):
642         logger.log("Received signal {} - exiting".format(signum))
643         os._exit(1)
644     signal.signal(signal.SIGHUP, handler)
645     signal.signal(signal.SIGQUIT, handler)
646     signal.signal(signal.SIGINT, handler)
647     signal.signal(signal.SIGTERM, handler)