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