can't blindly decode() a pickle, need to store as bytes
[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, binary=False):
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     open_mode = 'wb' if binary else 'w'
164     f = os.fdopen(fd, open_mode)
165     try:
166         do_write(f)
167     finally:
168         f.close()
169     return temporary_filename
170
171
172 def replace_file_with_string(target, new_contents,
173                              chmod=None, remove_if_empty=False):
174     """
175 Replace a target file with a new contents
176 checks for changes: does not do anything if previous state was already right
177 can handle chmod if requested
178 can also remove resulting file if contents are void, if requested
179 performs atomically:
180 writes in a tmp file, which is then renamed (from sliverauth originally)
181 returns True if a change occurred, or the file is deleted
182     """
183     try:
184         with open(target) as feed:
185             current = feed.read()
186     except:
187         current = ""
188     if current == new_contents:
189         # if turns out to be an empty string, and remove_if_empty is set,
190         # then make sure to trash the file if it exists
191         if remove_if_empty and not new_contents and os.path.isfile(target):
192             logger.verbose(
193                 "tools.replace_file_with_string: removing file {}".format(target))
194             try:
195                 os.unlink(target)
196             finally:
197                 return True
198         return False
199     # overwrite target file: create a temp in the same directory
200     path = os.path.dirname(target) or '.'
201     fd, name = tempfile.mkstemp('', 'repl', path)
202     os.write(fd, new_contents.encode())
203     os.close(fd)
204     if os.path.exists(target):
205         os.unlink(target)
206     shutil.move(name, target)
207     if chmod:
208         os.chmod(target, chmod)
209     return True
210
211 ####################
212 # utilities functions to get (cached) information from the node
213
214
215 # get node_id from /etc/planetlab/node_id and cache it
216 _node_id = None
217
218
219 def node_id():
220     global _node_id
221     if _node_id is None:
222         try:
223             with open("/etc/planetlab/node_id") as f:
224                 _node_id = int(f.read())
225         except:
226             _node_id = ""
227     return _node_id
228
229
230 _root_context_arch = None
231
232
233 def root_context_arch():
234     global _root_context_arch
235     if not _root_context_arch:
236         sp = subprocess.Popen(["uname", "-i"], stdout=subprocess.PIPE)
237         (_root_context_arch, _) = sp.communicate()
238         _root_context_arch = _root_context_arch.strip()
239     return _root_context_arch
240
241
242 ####################
243 class NMLock:
244     def __init__(self, file):
245         logger.log("tools: Lock {} initialized.".format(file), 2)
246         self.fd = os.open(file, os.O_RDWR | os.O_CREAT, 0o600)
247         flags = fcntl.fcntl(self.fd, fcntl.F_GETFD)
248         flags |= fcntl.FD_CLOEXEC
249         fcntl.fcntl(self.fd, fcntl.F_SETFD, flags)
250
251     def __del__(self):
252         os.close(self.fd)
253
254     def acquire(self):
255         logger.log("tools: Lock acquired.", 2)
256         fcntl.lockf(self.fd, fcntl.LOCK_SH)
257
258     def release(self):
259         logger.log("tools: Lock released.", 2)
260         fcntl.lockf(self.fd, fcntl.LOCK_UN)
261
262 ####################
263 # Utilities for getting the IP address of a LXC/Openvswitch slice. Do this by
264 # running ifconfig inside of the slice's context.
265
266
267 def get_sliver_process(slice_name, process_cmdline):
268     """
269     Utility function to find a process inside of an LXC sliver. Returns
270     (cgroup_fn, pid). cgroup_fn is the filename of the cgroup file for
271     the process, for example /proc/2592/cgroup. Pid is the process id of
272     the process. If the process is not found then (None, None) is returned.
273     """
274     try:
275         cmd = 'grep {} /proc/*/cgroup | grep freezer'.format(slice_name)
276         output = os.popen(cmd).readlines()
277     except:
278         # the slice couldn't be found
279         logger.log(
280             "get_sliver_process: couldn't find slice {}".format(slice_name))
281         return (None, None)
282
283     cgroup_fn = None
284     pid = None
285     for e in output:
286         try:
287             l = e.rstrip()
288             path = l.split(':')[0]
289             comp = l.rsplit(':')[-1]
290             slice_name_check = comp.rsplit('/')[-1]
291             # the lines below were added by Guilherme <gsm@machados.org>
292             # due to the LXC requirements
293             # What we have to consider here is that libervirt on Fedora 18
294             # uses the following line:
295             # /proc/1253/cgroup:6:freezer:/machine.slice/auto_sirius.libvirt-lxc
296             # While the libvirt on Fedora 20 and 21 uses the following line:
297             # /proc/1253/cgroup:6:freezer:/machine.slice/machine-lxc\x2del_sirius.scope
298             # Further documentation on:
299             # https://libvirt.org/cgroups.html#systemdScope
300             virt = get_node_virt()
301             if virt == 'lxc':
302                 # This is for Fedora 20 or later
303                 regexf20orlater = re.compile(r'machine-lxc\\x2d(.+).scope')
304                 isf20orlater = regexf20orlater.search(slice_name_check)
305                 if isf20orlater:
306                     slice_name_check = isf20orlater.group(1)
307                 else:
308                     # This is for Fedora 18
309                     slice_name_check = slice_name_check.rsplit('.')[0]
310
311             if (slice_name_check == slice_name):
312                 slice_path = path
313                 pid = slice_path.split('/')[2]
314                 with open('/proc/{}/cmdline'.format(pid)) as cmdfile:
315                     cmdline = cmdfile.read().rstrip('\n\x00')
316                 if (cmdline == process_cmdline):
317                     cgroup_fn = slice_path
318                     break
319         except:
320             break
321
322     if (not cgroup_fn) or (not pid):
323         logger.log("get_sliver_process: process {} not running in slice {}"
324                    .format(process_cmdline, slice_name))
325         return (None, None)
326
327     return (cgroup_fn, pid)
328
329 ###################################################
330 # Added by Guilherme Sperb Machado <gsm@machados.org>
331 ###################################################
332
333
334 try:
335     import re
336     import socket
337     import fileinput
338 except:
339     logger.log("Could not import 're', 'socket', or 'fileinput' python packages.")
340
341 # TODO: is there anything better to do if the "libvirt", "sliver_libvirt",
342 # and "sliver_lxc" are not in place?
343 try:
344     import libvirt
345     from sliver_libvirt import Sliver_Libvirt
346     import sliver_lxc
347 except:
348     logger.log("Could not import 'sliver_lxc' or 'libvirt' or 'sliver_libvirt'.")
349 ###################################################
350
351
352 def get_sliver_ifconfig(slice_name, device="eth0"):
353     """
354     return the output of "ifconfig" run from inside the sliver.
355
356     side effects: adds "/usr/sbin" to sys.path
357     """
358
359     # See if setns is installed. If it's not then we're probably not running
360     # LXC.
361     if not os.path.exists("/usr/sbin/setns.so"):
362         return None
363
364     # setns is part of lxcsu and is installed to /usr/sbin
365     if not "/usr/sbin" in sys.path:
366         sys.path.append("/usr/sbin")
367     import setns
368
369     (cgroup_fn, pid) = get_sliver_process(slice_name, "/sbin/init")
370     if (not cgroup_fn) or (not pid):
371         return None
372
373     path = '/proc/{}/ns/net'.format(pid)
374
375     result = None
376     try:
377         setns.chcontext(path)
378
379         args = ["/sbin/ifconfig", device]
380         sub = subprocess.Popen(
381             args, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
382         sub.wait()
383
384         if (sub.returncode != 0):
385             logger.log("get_slice_ifconfig: error in ifconfig: {}".format(
386                 sub.stderr.read()))
387
388         result = sub.stdout.read()
389     finally:
390         setns.chcontext("/proc/1/ns/net")
391
392     return result
393
394
395 def get_sliver_ip(slice_name):
396     ifconfig = get_sliver_ifconfig(slice_name)
397     if not ifconfig:
398         return None
399
400     for line in ifconfig.split("\n"):
401         if "inet addr:" in line:
402             # example: '          inet addr:192.168.122.189  Bcast:192.168.122.255  Mask:255.255.255.0'
403             parts = line.strip().split()
404             if len(parts) >= 2 and parts[1].startswith("addr:"):
405                 return parts[1].split(":")[1]
406
407     return None
408
409 ###################################################
410 # Author: Guilherme Sperb Machado <gsm@machados.org>
411 ###################################################
412 # Get the slice ipv6 address
413 # Only for LXC!
414 ###################################################
415
416
417 def get_sliver_ipv6(slice_name):
418     ifconfig = get_sliver_ifconfig(slice_name)
419     if not ifconfig:
420         return None, None
421
422     # example: 'inet6 2001:67c:16dc:1302:5054:ff:fea7:7882  prefixlen 64  scopeid 0x0<global>'
423     prog = re.compile(
424         r'inet6\s+(.*)\s+prefixlen\s+(\d+)\s+scopeid\s+(.+)<global>')
425     for line in ifconfig.split("\n"):
426         search = prog.search(line)
427         if search:
428             ipv6addr = search.group(1)
429             prefixlen = search.group(2)
430             return (ipv6addr, prefixlen)
431     return None, None
432
433 ###################################################
434 # Author: Guilherme Sperb Machado <gsm@machados.org>
435 ###################################################
436 # Check if the address is a AF_INET6 family address
437 ###################################################
438
439
440 def is_valid_ipv6(ipv6addr):
441     try:
442         socket.inet_pton(socket.AF_INET6, ipv6addr)
443     except socket.error:
444         return False
445     return True
446
447
448 # this returns the kind of virtualization on the node
449 # either 'vs' or 'lxc'
450 # also caches it in /etc/planetlab/virt for next calls
451 # could be promoted to core nm if need be
452 virt_stamp = "/etc/planetlab/virt"
453
454
455 def get_node_virt():
456     try:
457         with open(virt_stamp) as f:
458             return f.read().strip()
459     except:
460         pass
461     logger.log("Computing virt..")
462     try:
463         virt = 'vs' if subprocess.call(['vserver', '--help']) == 0 else 'lxc'
464     except:
465         virt = 'lxc'
466     with open(virt_stamp, "w") as f:
467         f.write(virt)
468     return virt
469
470
471 # this return True or False to indicate that systemctl is present on that box
472 # cache result in memory as _has_systemctl
473 _has_systemctl = None
474
475
476 def has_systemctl():
477     global _has_systemctl
478     if _has_systemctl is None:
479         _has_systemctl = (subprocess.call(['systemctl', '--help']) == 0)
480     return _has_systemctl
481
482 ###################################################
483 # Author: Guilherme Sperb Machado <gsm@machados.org>
484 ###################################################
485 # This method was developed to support the ipv6 plugin
486 # Only for LXC!
487 ###################################################
488
489
490 def reboot_slivers():
491     type = 'sliver.LXC'
492     # connecting to the libvirtd
493     connLibvirt = Sliver_Libvirt.getConnection(type)
494     domains = connLibvirt.listAllDomains()
495     for domain in domains:
496         try:
497             # set the flag VIR_DOMAIN_REBOOT_INITCTL, which uses "initctl"
498             result = domain.reboot(0x04)
499             if result == 0:
500                 logger.log("tools: REBOOT {}".format(domain.name()))
501             else:
502                 raise Exception()
503         except Exception as e:
504             logger.log("tools: FAILED to reboot {} ({})".format(
505                 domain.name(), e))
506             logger.log(
507                 "tools: Trying to DESTROY/CREATE {} instead...".format(domain.name()))
508             try:
509                 result = domain.destroy()
510                 if result == 0:
511                     logger.log("tools: DESTROYED {}".format(domain.name()))
512                 else:
513                     logger.log(
514                         "tools: FAILED in the DESTROY call of {}".format(domain.name()))
515                 result = domain.create()
516                 if result == 0:
517                     logger.log("tools: CREATED {}".format(domain.name()))
518                 else:
519                     logger.log(
520                         "tools: FAILED in the CREATE call of {}".format(domain.name()))
521             except Exception as e:
522                 logger.log(
523                     "tools: FAILED to DESTROY/CREATE {} ({})".format(domain.name(), e))
524
525 ###################################################
526 # Author: Guilherme Sperb Machado <gsm@machados.org>
527 ###################################################
528 # Get the /etc/hosts file path
529 ###################################################
530
531
532 def get_hosts_file_path(slicename):
533     containerDir = os.path.join(sliver_lxc.Sliver_LXC.CON_BASE_DIR, slicename)
534     return os.path.join(containerDir, 'etc', 'hosts')
535
536 ###################################################
537 # Author: Guilherme Sperb Machado <gsm@machados.org>
538 ###################################################
539 # Search if there is a specific ipv6 address in the
540 # /etc/hosts file of a given slice
541 # If the parameter 'ipv6addr' is None, then search
542 # for any ipv6 address
543 ###################################################
544
545
546 def search_ipv6addr_hosts(slicename, ipv6addr):
547     hostsFilePath = get_hosts_file_path(slicename)
548     found = False
549     try:
550         for line in fileinput.input(r'{}'.format(hostsFilePath)):
551             if ipv6addr is not None:
552                 if re.search(r'{}'.format(ipv6addr), line):
553                     found = True
554             else:
555                 search = re.search(r'^(.*)\s+.*$', line)
556                 if search:
557                     ipv6candidate = search.group(1)
558                     ipv6candidatestrip = ipv6candidate.strip()
559                     valid = is_valid_ipv6(ipv6candidatestrip)
560                     if valid:
561                         found = True
562         fileinput.close()
563         return found
564     except:
565         logger.log("tools: FAILED to search {} in /etc/hosts file of slice={}"
566                    .format(ipv6addr, slicename))
567
568 ###################################################
569 # Author: Guilherme Sperb Machado <gsm@machados.org>
570 ###################################################
571 # Removes all ipv6 addresses from the /etc/hosts
572 # file of a given slice
573 ###################################################
574
575
576 def remove_all_ipv6addr_hosts(slicename, node):
577     hostsFilePath = get_hosts_file_path(slicename)
578     try:
579         for line in fileinput.input(r'{}'.format(hostsFilePath), inplace=True):
580             search = re.search(
581                 r'^(.*)\s+({}|{})$'.format(node, 'localhost'), line)
582             if search:
583                 ipv6candidate = search.group(1)
584                 ipv6candidatestrip = ipv6candidate.strip()
585                 valid = is_valid_ipv6(ipv6candidatestrip)
586                 if not valid:
587                     print(line, end=' ')
588         fileinput.close()
589         logger.log("tools: REMOVED IPv6 address from /etc/hosts file of slice={}"
590                    .format(slicename))
591     except:
592         logger.log("tools: FAILED to remove the IPv6 address from /etc/hosts file of slice={}"
593                    .format(slicename))
594
595 ###################################################
596 # Author: Guilherme Sperb Machado <gsm@machados.org>
597 ###################################################
598 # Adds an ipv6 address to the /etc/hosts file within a slice
599 ###################################################
600
601
602 def add_ipv6addr_hosts_line(slicename, node, ipv6addr):
603     hostsFilePath = get_hosts_file_path(slicename)
604     logger.log("tools: {}".format(hostsFilePath))
605     # debugging purposes:
606     #string = "127.0.0.1\tlocalhost\n192.168.100.179\tmyplc-node1-vm.mgmt.local\n"
607     #string = "127.0.0.1\tlocalhost\n"
608     try:
609         with open(hostsFilePath, "a") as file:
610             file.write(ipv6addr + " " + node + "\n")
611             file.close()
612         logger.log("tools: ADDED IPv6 address to /etc/hosts file of slice={}"
613                    .format(slicename))
614     except:
615         logger.log("tools: FAILED to add the IPv6 address to /etc/hosts file of slice={}"
616                    .format(slicename))
617
618
619 # how to run a command in a slice
620 # now this is a painful matter
621 # the problem is with capsh that forces a bash command to be injected in its exec'ed command
622 # so because lxcsu uses capsh, you cannot exec anything else than bash
623 # bottom line is, what actually needs to be called is
624 # vs:  vserver exec slicename command and its arguments
625 # lxc: lxcsu slicename "command and its arguments"
626 # which, OK, is no big deal as long as the command is simple enough,
627 # but do not stretch it with arguments that have spaces or need quoting as that will become a nightmare
628 def command_in_slice(slicename, argv):
629     virt = get_node_virt()
630     if virt == 'vs':
631         return ['vserver', slicename, 'exec', ] + argv
632     elif virt == 'lxc':
633         # wrap up argv in a single string for -c
634         return ['lxcsu', slicename, ] + [" ".join(argv)]
635     logger.log("command_in_slice: WARNING: could not find a valid virt")
636     return argv
637
638 ####################
639
640
641 def init_signals():
642     def handler(signum, frame):
643         logger.log("Received signal {} - exiting".format(signum))
644         os._exit(1)
645     signal.signal(signal.SIGHUP, handler)
646     signal.signal(signal.SIGQUIT, handler)
647     signal.signal(signal.SIGINT, handler)
648     signal.signal(signal.SIGTERM, handler)