applied the except and raise fixers to the master branch to close the gap with py3
[nepi.git] / src / nepi / resources / planetlab / node.py
1 #
2 #    NEPI, a framework to manage network experiments
3 #    Copyright (C) 2013 INRIA
4 #
5 #    This program is free software: you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License version 2 as
7 #    published by the Free Software Foundation;
8 #
9 #    This program is distributed in the hope that it will be useful,
10 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
11 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 #    GNU General Public License for more details.
13 #
14 #    You should have received a copy of the GNU General Public License
15 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 #
17 # Author: Alina Quereilhac <alina.quereilhac@inria.fr>
18 #         Lucia Guevgeozian <lucia.guevgeozian_odizzio@inria.fr>
19
20 from nepi.execution.attribute import Attribute, Flags, Types
21 from nepi.execution.resource import ResourceManager, clsinit_copy, \
22         ResourceState 
23 from nepi.resources.linux.node import LinuxNode
24 from nepi.resources.planetlab.plcapi import PLCAPIFactory 
25 from nepi.util.execfuncs import lexec
26 from nepi.util import sshfuncs
27
28 from random import randint
29 import re
30 import os
31 import time
32 import socket
33 import threading
34 import datetime
35 import weakref
36
37 @clsinit_copy
38 class PlanetlabNode(LinuxNode):
39     _rtype = "planetlab::Node"
40     _help = "Controls a PlanetLab host accessible using a SSH key " \
41             "associated to a PlanetLab user account"
42     _platform = "planetlab"
43
44     lock = threading.Lock()
45
46     @classmethod
47     def _register_attributes(cls):
48         ip = Attribute("ip", "PlanetLab host public IP address",
49                     flags = Flags.Design)
50
51         pl_url = Attribute("plcApiUrl", "URL of PlanetLab PLCAPI host \
52                     (e.g. www.planet-lab.eu or www.planet-lab.org) ",
53                     default = "www.planet-lab.eu",
54                     flags = Flags.Credential)
55
56         pl_ptn = Attribute("plcApiPattern", "PLC API service regexp pattern \
57                     (e.g. https://%(hostname)s:443/PLCAPI/ ) ",
58                     default = "https://%(hostname)s:443/PLCAPI/",
59                     flags = Flags.Design)
60     
61         pl_user = Attribute("pluser", "PlanetLab account user, as the one to \
62                     authenticate in the website) ",
63                     flags = Flags.Credential)
64
65         pl_password = Attribute("plpassword", 
66                         "PlanetLab account password, as \
67                         the one to authenticate in the website) ",
68                         flags = Flags.Credential)
69
70         city = Attribute("city", "Constrain location (city) during resource \
71                 discovery. May use wildcards.",
72                 flags = Flags.Filter)
73
74         country = Attribute("country", "Constrain location (country) during \
75                     resource discovery. May use wildcards.",
76                     flags = Flags.Filter)
77
78         region = Attribute("region", "Constrain location (region) during \
79                     resource discovery. May use wildcards.",
80                     flags = Flags.Filter)
81
82         architecture = Attribute("architecture", "Constrain architecture \
83                         during resource discovery.",
84                         type = Types.Enumerate,
85                         allowed = ["x86_64", 
86                                     "i386"],
87                         flags = Flags.Filter)
88
89         operating_system = Attribute("operatingSystem", "Constrain operating \
90                             system during resource discovery.",
91                             type = Types.Enumerate,
92                             allowed =  ["f8",
93                                         "f12",
94                                         "f14",
95                                         "centos",
96                                         "other"],
97                             flags = Flags.Filter)
98
99         min_reliability = Attribute("minReliability", "Constrain reliability \
100                             while picking PlanetLab nodes. Specifies a lower \
101                             acceptable bound.",
102                             type = Types.Double,
103                             range = (1, 100),
104                             flags = Flags.Filter)
105
106         max_reliability = Attribute("maxReliability", "Constrain reliability \
107                             while picking PlanetLab nodes. Specifies an upper \
108                             acceptable bound.",
109                             type = Types.Double,
110                             range = (1, 100),
111                             flags = Flags.Filter)
112
113         min_bandwidth = Attribute("minBandwidth", "Constrain available \
114                             bandwidth while picking PlanetLab nodes. \
115                             Specifies a lower acceptable bound.",
116                             type = Types.Double,
117                             range = (0, 2**31),
118                             flags = Flags.Filter)
119
120         max_bandwidth = Attribute("maxBandwidth", "Constrain available \
121                             bandwidth while picking PlanetLab nodes. \
122                             Specifies an upper acceptable bound.",
123                             type = Types.Double,
124                             range = (0, 2**31),
125                             flags = Flags.Filter)
126
127         min_load = Attribute("minLoad", "Constrain node load average while \
128                     picking PlanetLab nodes. Specifies a lower acceptable \
129                     bound.",
130                     type = Types.Double,
131                     range = (0, 2**31),
132                     flags = Flags.Filter)
133
134         max_load = Attribute("maxLoad", "Constrain node load average while \
135                     picking PlanetLab nodes. Specifies an upper acceptable \
136                     bound.",
137                     type = Types.Double,
138                     range = (0, 2**31),
139                     flags = Flags.Filter)
140
141         min_cpu = Attribute("minCpu", "Constrain available cpu time while \
142                     picking PlanetLab nodes. Specifies a lower acceptable \
143                     bound.",
144                     type = Types.Double,
145                     range = (0, 100),
146                     flags = Flags.Filter)
147
148         max_cpu = Attribute("maxCpu", "Constrain available cpu time while \
149                     picking PlanetLab nodes. Specifies an upper acceptable \
150                     bound.",
151                     type = Types.Double,
152                     range = (0, 100),
153                     flags = Flags.Filter)
154
155         timeframe = Attribute("timeframe", "Past time period in which to check\
156                         information about the node. Values are year,month, \
157                         week, latest",
158                         default = "week",
159                         type = Types.Enumerate,
160                         allowed = ["latest",
161                                     "week",
162                                     "month",
163                                     "year"],
164                         flags = Flags.Filter)
165
166         plblacklist = Attribute("persist_blacklist", "Take into account the file plblacklist \
167                         in the user's home directory under .nepi directory. This file \
168                         contains a list of PL nodes to blacklist, and at the end \
169                         of the experiment execution the new blacklisted nodes are added.",
170                     type = Types.Bool,
171                     default = False,
172                     flags = Flags.Global)
173
174         cls._register_attribute(ip)
175         cls._register_attribute(pl_url)
176         cls._register_attribute(pl_ptn)
177         cls._register_attribute(pl_user)
178         cls._register_attribute(pl_password)
179         cls._register_attribute(city)
180         cls._register_attribute(country)
181         cls._register_attribute(region)
182         cls._register_attribute(architecture)
183         cls._register_attribute(operating_system)
184         cls._register_attribute(min_reliability)
185         cls._register_attribute(max_reliability)
186         cls._register_attribute(min_bandwidth)
187         cls._register_attribute(max_bandwidth)
188         cls._register_attribute(min_load)
189         cls._register_attribute(max_load)
190         cls._register_attribute(min_cpu)
191         cls._register_attribute(max_cpu)
192         cls._register_attribute(timeframe)
193         cls._register_attribute(plblacklist)
194
195     def __init__(self, ec, guid):
196         super(PlanetlabNode, self).__init__(ec, guid)
197
198         self._ecobj = weakref.ref(ec)
199         self._plapi = None
200         self._node_to_provision = None
201         self._slicenode = False
202         self._hostname = False
203
204         if self.get("gateway") or self.get("gatewayUser"):
205             self.set("gateway", None)
206             self.set("gatewayUser", None)
207
208         # Blacklist file
209         nepi_home = os.path.join(os.path.expanduser("~"), ".nepi")
210         plblacklist_file = os.path.join(nepi_home, "plblacklist.txt")
211         if not os.path.exists(plblacklist_file):
212             if os.path.isdir(nepi_home):
213                 with open(plblacklist_file, 'w') as clear:
214                     pass
215             else:
216                 os.makedirs(nepi_home)
217                 with open(plblacklist_file, 'w') as clear:
218                     pass
219
220     def _skip_provision(self):
221         pl_user = self.get("pluser")
222         pl_pass = self.get("plpassword")
223         if not pl_user and not pl_pass:
224             return True
225         else: return False
226     
227     @property
228     def plapi(self):
229         if not self._plapi:
230             pl_user = self.get("pluser")
231             pl_pass = self.get("plpassword")
232             pl_url = self.get("plcApiUrl")
233             pl_ptn = self.get("plcApiPattern")
234             _plapi = PLCAPIFactory.get_api(pl_user, pl_pass, pl_url,
235                 pl_ptn, self._ecobj())
236             
237             if not _plapi:
238                 self.fail_plapi()
239         
240             self._plapi = weakref.ref(_plapi)
241
242         return self._plapi()
243
244     def do_discover(self):
245         """
246         Based on the attributes defined by the user, discover the suitable 
247         nodes for provision.
248         """
249         if self._skip_provision():
250             super(PlanetlabNode, self).do_discover()
251             return
252
253         hostname = self._get_hostname()
254         if hostname:
255             # the user specified one particular node to be provisioned
256             self._hostname = True
257             node_id = self._get_nodes_id({'hostname':hostname})
258             node_id = node_id.pop()['node_id']
259
260             # check that the node is not blacklisted or being provisioned
261             # by other RM
262             with PlanetlabNode.lock:
263                 plist = self.plapi.reserved()
264                 blist = self.plapi.blacklisted()
265                 if node_id not in blist and node_id not in plist:
266                 
267                     # check that is really alive, by performing ping
268                     ping_ok = self._do_ping(node_id)
269                     if not ping_ok:
270                         self._blacklist_node(node_id)
271                         self.fail_node_not_alive(hostname)
272                     else:
273                         if self._check_if_in_slice([node_id]):
274                             self._slicenode = True
275                         self._put_node_in_provision(node_id)
276                         self._node_to_provision = node_id
277                 else:
278                     self.fail_node_not_available(hostname)
279             super(PlanetlabNode, self).do_discover()
280         
281         else:
282             # the user specifies constraints based on attributes, zero, one or 
283             # more nodes can match these constraints 
284             nodes = self._filter_based_on_attributes()
285
286             # nodes that are already part of user's slice have the priority to
287             # provisioned
288             nodes_inslice = self._check_if_in_slice(nodes)
289             nodes_not_inslice = list(set(nodes) - set(nodes_inslice))
290             
291             node_id = None
292             if nodes_inslice:
293                 node_id = self._choose_random_node(nodes_inslice)
294                 self._slicenode = True                
295                 
296             if not node_id:
297                 # Either there were no matching nodes in the user's slice, or
298                 # the nodes in the slice  were blacklisted or being provisioned
299                 # by other RM. Note nodes_not_inslice is never empty
300                 node_id = self._choose_random_node(nodes_not_inslice)
301                 self._slicenode = False
302
303             if node_id:
304                 self._node_to_provision = node_id
305                 try:
306                     self._set_hostname_attr(node_id)
307                     self.info(" Selected node to provision ")
308                     super(PlanetlabNode, self).do_discover()
309                 except:
310                     with PlanetlabNode.lock:
311                         self._blacklist_node(node_id)
312                     self.do_discover()
313             else:
314                self.fail_not_enough_nodes() 
315             
316     def do_provision(self):
317         """
318         Add node to user's slice after verifing that the node is functioning
319         correctly
320         """
321         if self._skip_provision():
322             super(PlanetlabNode, self).do_provision()
323             return
324
325         provision_ok = False
326         ssh_ok = False
327         proc_ok = False
328         timeout = 1800
329
330         while not provision_ok:
331             node = self._node_to_provision
332             if not self._slicenode:
333                 self._add_node_to_slice(node)
334                 if self._check_if_in_slice([node]):
335                     self.debug( "Node added to slice" )
336                 else:
337                     self.warning(" Could not add to slice ")
338                     with PlanetlabNode.lock:
339                         self._blacklist_node(node)
340                     self.do_discover()
341                     continue
342
343                 # check ssh connection
344                 t = 0 
345                 while t < timeout and not ssh_ok:
346
347                     cmd = 'echo \'GOOD NODE\''
348                     ((out, err), proc) = self.execute(cmd)
349                     if out.find("GOOD NODE") < 0:
350                         self.debug( "No SSH connection, waiting 60s" )
351                         t = t + 60
352                         time.sleep(60)
353                         continue
354                     else:
355                         self.debug( "SSH OK" )
356                         ssh_ok = True
357                         continue
358             else:
359                 cmd = 'echo \'GOOD NODE\''
360                 ((out, err), proc) = self.execute(cmd)
361                 if not out.find("GOOD NODE") < 0:
362                     ssh_ok = True
363
364             if not ssh_ok:
365                 # the timeout was reach without establishing ssh connection
366                 # the node is blacklisted, deleted from the slice, and a new
367                 # node to provision is discovered
368                 with PlanetlabNode.lock:
369                     self.warning(" Could not SSH login ")
370                     self._blacklist_node(node)
371                     #self._delete_node_from_slice(node)
372                 self.do_discover()
373                 continue
374             
375             # check /proc directory is mounted (ssh_ok = True)
376             # and file system is not read only
377             else:
378                 cmd = 'mount |grep proc'
379                 ((out1, err1), proc1) = self.execute(cmd)
380                 cmd = 'touch /tmp/tmpfile; rm /tmp/tmpfile'
381                 ((out2, err2), proc2) = self.execute(cmd)
382                 if out1.find("/proc type proc") < 0 or \
383                     "Read-only file system".lower() in err2.lower():
384                     with PlanetlabNode.lock:
385                         self.warning(" Corrupted file system ")
386                         self._blacklist_node(node)
387                         #self._delete_node_from_slice(node)
388                     self.do_discover()
389                     continue
390             
391                 else:
392                     provision_ok = True
393                     if not self.get('hostname'):
394                         self._set_hostname_attr(node)            
395                     # set IP attribute
396                     ip = self._get_ip(node)
397                     self.set("ip", ip)
398                     self.info(" Node provisioned ")            
399             
400         super(PlanetlabNode, self).do_provision()
401
402     def do_release(self):
403         super(PlanetlabNode, self).do_release()
404         if self.state == ResourceState.RELEASED and not self._skip_provision():
405             self.debug(" Releasing PLC API ")
406             self.plapi.release()
407
408     def _filter_based_on_attributes(self):
409         """
410         Retrive the list of nodes ids that match user's constraints 
411         """
412         # Map user's defined attributes with tagnames of PlanetLab
413         timeframe = self.get("timeframe")[0]
414         attr_to_tags = {
415             'city' : 'city',
416             'country' : 'country',
417             'region' : 'region',
418             'architecture' : 'arch',
419             'operatingSystem' : 'fcdistro',
420             'minReliability' : 'reliability%s' % timeframe,
421             'maxReliability' : 'reliability%s' % timeframe,
422             'minBandwidth' : 'bw%s' % timeframe,
423             'maxBandwidth' : 'bw%s' % timeframe,
424             'minLoad' : 'load%s' % timeframe,
425             'maxLoad' : 'load%s' % timeframe,
426             'minCpu' : 'cpu%s' % timeframe,
427             'maxCpu' : 'cpu%s' % timeframe,
428         }
429         
430         nodes_id = []
431         filters = {}
432
433         for attr_name, attr_obj in self._attrs.iteritems():
434             attr_value = self.get(attr_name)
435             
436             if attr_value is not None and attr_obj.has_flag(Flags.Filter) and \
437                 attr_name != 'timeframe':
438         
439                 attr_tag = attr_to_tags[attr_name]
440                 filters['tagname'] = attr_tag
441
442                 # filter nodes by fixed constraints e.g. operating system
443                 if not 'min' in attr_name and not 'max' in attr_name:
444                     filters['value'] = attr_value
445                     nodes_id = self._filter_by_fixed_attr(filters, nodes_id)
446
447                 # filter nodes by range constraints e.g. max bandwidth
448                 elif ('min' or 'max') in attr_name:
449                     nodes_id = self._filter_by_range_attr(attr_name, attr_value, filters, nodes_id)
450
451         if not filters:
452             nodes = self._get_nodes_id()
453             for node in nodes:
454                 nodes_id.append(node['node_id'])
455         return nodes_id
456                     
457     def _filter_by_fixed_attr(self, filters, nodes_id):
458         """
459         Query PLCAPI for nodes ids matching fixed attributes defined by the
460         user
461         """
462         node_tags = self.plapi.get_node_tags(filters)
463         if node_tags is not None:
464
465             if len(nodes_id) == 0:
466                 # first attribute being matched
467                 for node_tag in node_tags:
468                     nodes_id.append(node_tag['node_id'])
469             else:
470                 # remove the nodes ids that don't match the new attribute
471                 # that is being match
472
473                 nodes_id_tmp = []
474                 for node_tag in node_tags:
475                     if node_tag['node_id'] in nodes_id:
476                         nodes_id_tmp.append(node_tag['node_id'])
477
478                 if len(nodes_id_tmp):
479                     nodes_id = set(nodes_id) & set(nodes_id_tmp)
480                 else:
481                     # no node from before match the new constraint
482                     self.fail_discovery()
483         else:
484             # no nodes match the filter applied
485             self.fail_discovery()
486
487         return nodes_id
488
489     def _filter_by_range_attr(self, attr_name, attr_value, filters, nodes_id):
490         """
491         Query PLCAPI for nodes ids matching attributes defined in a certain
492         range, by the user
493         """
494         node_tags = self.plapi.get_node_tags(filters)
495         if node_tags:
496             
497             if len(nodes_id) == 0:
498                 # first attribute being matched
499                 for node_tag in node_tags:
500  
501                    # check that matches the min or max restriction
502                     if 'min' in attr_name and node_tag['value'] != 'n/a' and \
503                         float(node_tag['value']) > attr_value:
504                         nodes_id.append(node_tag['node_id'])
505
506                     elif 'max' in attr_name and node_tag['value'] != 'n/a' and \
507                         float(node_tag['value']) < attr_value:
508                         nodes_id.append(node_tag['node_id'])
509             else:
510
511                 # remove the nodes ids that don't match the new attribute
512                 # that is being match
513                 nodes_id_tmp = []
514                 for node_tag in node_tags:
515
516                     # check that matches the min or max restriction and was a
517                     # matching previous filters
518                     if 'min' in attr_name and node_tag['value'] != 'n/a' and \
519                         float(node_tag['value']) > attr_value and \
520                         node_tag['node_id'] in nodes_id:
521                         nodes_id_tmp.append(node_tag['node_id'])
522
523                     elif 'max' in attr_name and node_tag['value'] != 'n/a' and \
524                         float(node_tag['value']) < attr_value and \
525                         node_tag['node_id'] in nodes_id:
526                         nodes_id_tmp.append(node_tag['node_id'])
527
528                 if len(nodes_id_tmp):
529                     nodes_id = set(nodes_id) & set(nodes_id_tmp)
530                 else:
531                     # no node from before match the new constraint
532                     self.fail_discovery()
533
534         else: #TODO CHECK
535             # no nodes match the filter applied
536             self.fail_discovery()
537
538         return nodes_id
539         
540     def _choose_random_node(self, nodes):
541         """
542         From the possible nodes for provision, choose randomly to decrese the
543         probability of different RMs choosing the same node for provision
544         """
545         size = len(nodes)
546         while size:
547             size = size - 1
548             index = randint(0, size)
549             node_id = nodes[index]
550             nodes[index] = nodes[size]
551
552             # check the node is not blacklisted or being provision by other RM
553             # and perform ping to check that is really alive
554             with PlanetlabNode.lock:
555
556                 blist = self.plapi.blacklisted()
557                 plist = self.plapi.reserved()
558                 if node_id not in blist and node_id not in plist:
559                     ping_ok = self._do_ping(node_id)
560                     if not ping_ok:
561                         self._set_hostname_attr(node_id)
562                         self.warning(" Node not responding PING ")
563                         self._blacklist_node(node_id)
564                     else:
565                         # discovered node for provision, added to provision list
566                         self._put_node_in_provision(node_id)
567                         return node_id
568
569     def _get_nodes_id(self, filters=None):
570         return self.plapi.get_nodes(filters, fields=['node_id'])
571
572     def _add_node_to_slice(self, node_id):
573         self.info(" Adding node to slice ")
574         slicename = self.get("username")
575         with PlanetlabNode.lock:
576             slice_nodes = self.plapi.get_slice_nodes(slicename)
577             self.debug(" Previous slice nodes %s " % slice_nodes)
578             slice_nodes.append(node_id)
579             self.plapi.add_slice_nodes(slicename, slice_nodes)
580
581     def _delete_node_from_slice(self, node):
582         self.warning(" Deleting node from slice ")
583         slicename = self.get("username")
584         self.plapi.delete_slice_node(slicename, [node])
585
586     def _get_hostname(self):
587         hostname = self.get("hostname")
588         if hostname:
589             return hostname
590         ip = self.get("ip")
591         if ip:
592             hostname = socket.gethostbyaddr(ip)[0]
593             self.set('hostname', hostname)
594             return hostname
595         else:
596             return None
597
598     def _set_hostname_attr(self, node):
599         """
600         Query PLCAPI for the hostname of a certain node id and sets the
601         attribute hostname, it will over write the previous value
602         """
603         hostname = self.plapi.get_nodes(node, ['hostname'])
604         self.set("hostname", hostname[0]['hostname'])
605
606     def _check_if_in_slice(self, nodes_id):
607         """
608         Query PLCAPI to find out if any node id from nodes_id is in the user's
609         slice
610         """
611         slicename = self.get("username")
612         slice_nodes = self.plapi.get_slice_nodes(slicename)
613         nodes_inslice = list(set(nodes_id) & set(slice_nodes))
614         return nodes_inslice
615
616     def _do_ping(self, node_id):
617         """
618         Perform ping command on node's IP matching node id
619         """
620         ping_ok = False
621         ip = self._get_ip(node_id)
622         if ip:
623             command = "ping -c4 %s" % ip
624             (out, err) = lexec(command)
625
626             m = re.search("(\d+)% packet loss", str(out))
627             if m and int(m.groups()[0]) < 50:
628                 ping_ok = True
629        
630         return ping_ok 
631
632     def _blacklist_node(self, node):
633         """
634         Add node mal functioning node to blacklist
635         """
636         self.warning(" Blacklisting malfunctioning node ")
637         self.plapi.blacklist_host(node)
638         if not self._hostname:
639             self.set('hostname', None)
640
641     def _put_node_in_provision(self, node):
642         """
643         Add node to the list of nodes being provisioned, in order for other RMs
644         to not try to provision the same one again
645         """
646         self.plapi.reserve_host(node)
647
648     def _get_ip(self, node_id):
649         """
650         Query PLCAPI for the IP of a node with certain node id
651         """
652         hostname = self.get("hostname") or \
653             self.plapi.get_nodes(node_id, ['hostname'])[0]['hostname']
654         try:
655             ip = sshfuncs.gethostbyname(hostname)
656         except:
657             # Fail while trying to find the IP
658             return None
659         return ip
660
661     def fail_discovery(self):
662         msg = "Discovery failed. No candidates found for node"
663         self.error(msg)
664         raise RuntimeError(msg)
665
666     def fail_node_not_alive(self, hostname=None):
667         msg = "Node %s not alive" % hostname
668         raise RuntimeError(msg)
669     
670     def fail_node_not_available(self, hostname):
671         msg = "Node %s not available for provisioning" % hostname
672         raise RuntimeError(msg)
673
674     def fail_not_enough_nodes(self):
675         msg = "Not enough nodes available for provisioning"
676         raise RuntimeError(msg)
677
678     def fail_plapi(self):
679         msg = "Failing while trying to instanciate the PLC API.\nSet the" + \
680             " attributes pluser and plpassword."
681         raise RuntimeError(msg)
682
683     def valid_connection(self, guid):
684         # TODO: Validate!
685         return True
686
687