Adding flag when caching nodes, decrease waiting time
[nepi.git] / src / nepi / resources / planetlab / plcapi.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 as published by
7 #    the Free Software Foundation, either version 3 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14 #
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 #
18 # Author: Alina Quereilhac <alina.quereilhac@inria.fr>
19
20 import functools
21 import hashlib
22 import socket
23 import time
24 import threading
25 import xmlrpclib
26
27 def _retry(fn):
28     def rv(*p, **kw):
29         for x in xrange(5):
30             try:
31                 return fn(*p, **kw)
32             except (socket.error, IOError, OSError):
33                 time.sleep(x*5+5)
34         else:
35             return fn (*p, **kw)
36     return rv
37
38 class PLCAPI(object):
39
40     _expected_methods = set(
41         ['AddNodeTag', 'AddConfFile', 'DeletePersonTag', 'AddNodeType',
42          'DeleteBootState', 'SliceListNames', 'DeleteKey','SliceGetTicket', 
43          'SliceUsersList', 'SliceUpdate', 'GetNodeGroups', 'SliceCreate', 
44          'GetNetworkMethods', 'GetNodeFlavour', 'DeleteNode', 'BootNotifyOwners',
45          'AddPersonKey', 'AddNode', 'UpdateNodeGroup', 'GetAddressTypes',
46          'AddIlink', 'DeleteNetworkType', 'GetInitScripts', 'GenerateNodeConfFile',
47          'AddSite', 'BindObjectToPeer', 'SliceListUserSlices', 'GetPeers',
48          'AddPeer', 'DeletePeer', 'AddRole', 'DeleteRole', 'SetPersonPrimarySite',
49          'AddSiteAddress', 'SliceDelete', 'NotifyPersons', 'GetKeyTypes',
50          'GetConfFiles', 'GetIlinks', 'AddTagType', 'GetNodes', 'DeleteNodeTag',
51          'DeleteSliceFromNodesWhitelist', 'UpdateAddress', 'ResetPassword', 
52          'AddSliceToNodesWhitelist', 'AddRoleToTagType', 'AddLeases',
53          'GetAddresses', 'AddInitScript', 'RebootNode', 'GetPCUTypes', 
54          'RefreshPeer', 'GetBootMedium', 'UpdateKey', 'UpdatePCU', 'GetSession',
55          'AddInterfaceTag', 'UpdatePCUType', 'GetInterfaces', 'SliceExtendedInfo',
56          'SliceNodesList', 'DeleteRoleFromTagType', 'DeleteSlice', 'GetSites',
57          'DeleteMessage', 'GetSliceFamily', 'GetPlcRelease', 'UpdateTagType',
58          'AddSliceInstantiation', 'ResolveSlices', 'GetSlices',
59          'DeleteRoleFromPerson', 'GetSessions', 'UpdatePeer', 'VerifyPerson',
60          'GetPersonTags', 'DeleteKeyType', 'AddSlice', 'SliceUserAdd',
61          'DeleteSession', 'GetMessages', 'DeletePCU', 'GetPeerData',
62          'DeletePersonFromSite', 'DeleteTagType', 'GetPCUs', 'UpdateLeases',
63          'AddMessage', 'DeletePCUProtocolType', 'DeleteInterfaceTag',
64          'AddPersonToSite', 'GetSlivers', 'SliceNodesDel',
65          'DeleteAddressTypeFromAddress', 'AddNodeGroup', 'GetSliceTags',
66          'DeleteSite', 'GetSiteTags', 'UpdateMessage', 'DeleteSliceFromNodes',
67          'SliceRenew', 'UpdatePCUProtocolType', 'DeleteSiteTag',
68          'GetPCUProtocolTypes', 'GetEvents', 'GetSliceTicket', 'AddPersonTag',
69          'BootGetNodeDetails', 'DeleteInterface', 'DeleteNodeGroup',
70          'AddPCUProtocolType', 'BootCheckAuthentication', 'AddSiteTag',
71          'AddAddressTypeToAddress', 'DeleteConfFile', 'DeleteInitScript',
72          'DeletePerson', 'DeleteIlink', 'DeleteAddressType', 'AddBootState',
73          'AuthCheck', 'NotifySupport', 'GetSliceInstantiations', 'AddPCUType',
74          'AddPCU', 'AddSession', 'GetEventObjects', 'UpdateSiteTag', 
75          'UpdateNodeTag', 'AddPerson', 'BlacklistKey', 'UpdateInitScript',
76          'AddSliceToNodes', 'RebootNodeWithPCU', 'GetNodeTags', 'GetSliceKeys',
77          'GetSliceSshKeys', 'AddNetworkMethod', 'SliceNodesAdd',
78          'DeletePersonFromSlice', 'ReportRunlevel', 'GetNetworkTypes',
79          'UpdateSite', 'DeleteConfFileFromNodeGroup', 'UpdateNode',
80          'DeleteSliceInstantiation', 'DeleteSliceTag', 'BootUpdateNode',
81          'UpdatePerson', 'UpdateConfFile', 'SliceUserDel', 'DeleteLeases',
82          'AddConfFileToNodeGroup', 'UpdatePersonTag', 'DeleteConfFileFromNode',
83          'AddPersonToSlice', 'UnBindObjectFromPeer', 'AddNodeToPCU',
84          'GetLeaseGranularity', 'DeletePCUType', 'GetTagTypes', 'GetNodeTypes',
85          'UpdateInterfaceTag', 'GetRoles', 'UpdateSlice', 'UpdateSliceTag',
86          'AddSliceTag', 'AddNetworkType', 'AddInterface', 'AddAddressType',
87          'AddRoleToPerson', 'DeleteNodeType', 'GetLeases', 'UpdateInterface',
88          'SliceInfo', 'DeleteAddress', 'SliceTicketGet', 'GetPersons',
89          'GetWhitelist', 'AddKeyType', 'UpdateAddressType', 'GetPeerName',
90          'DeleteNetworkMethod', 'UpdateIlink', 'AddConfFileToNode', 'GetKeys',
91          'DeleteNodeFromPCU', 'GetInterfaceTags', 'GetBootStates',
92          'SetInterfaceSens', 'SetNodeLoadm', 'GetInterfaceRate', 'GetNodeLoadw',
93          'SetInterfaceKey', 'GetNodeSlices', 'GetNodeLoadm', 'SetSliceVref',
94          'GetInterfaceIwpriv', 'SetNodeLoadw', 'SetNodeSerial',
95          'GetNodePlainBootstrapfs', 'SetNodeMEMw', 'GetNodeResponse',
96          'SetInterfaceRate', 'SetSliceInitscript', 'SetNodeFcdistro',
97          'GetNodeLoady', 'SetNodeArch', 'SetNodeKargs', 'SetNodeMEMm',
98          'SetNodeBWy', 'SetNodeBWw', 'SetInterfaceSecurityMode', 'SetNodeBWm',
99          'SetNodeASType', 'GetNodeKargs', 'GetPersonColumnconf',
100          'GetNodeResponsem', 'GetNodeCPUy', 'GetNodeCramfs', 'SetNodeSlicesw',
101          'SetPersonColumnconf', 'SetNodeSlicesy', 'GetNodeCPUw', 'GetNodeBWy', 
102          'GetNodeCPUm', 'GetInterfaceDriver', 'GetNodeLoad', 'GetInterfaceMode',
103          'GetNodeSerial', 'SetNodeSlicesm', 'SetNodeLoady', 'GetNodeReliabilityw',
104          'SetSliceFcdistro', 'GetNodeReliabilityy', 'SetInterfaceEssid',
105          'SetSliceInitscriptCode', 'GetNodeExtensions', 'GetSliceOmfControl',
106          'SetNodeCity', 'SetInterfaceIfname', 'SetNodeHrn', 'SetNodeNoHangcheck', 
107          'GetNodeNoHangcheck', 'GetSliceFcdistro', 'SetNodeCountry',
108          'SetNodeKvariant', 'GetNodeKvariant', 'GetNodeMEMy', 'SetInterfaceIwpriv',
109          'GetNodeMEMw', 'SetInterfaceBackdoor', 'GetInterfaceFreq',
110          'SetInterfaceChannel', 'SetInterfaceNw', 'GetPersonShowconf',
111          'GetSliceInitscriptCode', 'SetNodeMEM', 'GetInterfaceEssid', 'GetNodeMEMm',
112          'SetInterfaceMode', 'SetInterfaceIwconfig', 'GetNodeSlicesm', 'GetNodeBWm',
113          'SetNodePlainBootstrapfs', 'SetNodeRegion', 'SetNodeCPU', 'GetNodeSlicesw',
114          'SetNodeBW', 'SetNodeSlices', 'SetNodeCramfs', 'GetNodeSlicesy',
115          'GetInterfaceKey', 'GetSliceInitscript', 'SetNodeCPUm', 'SetSliceArch',
116          'SetNodeLoad', 'SetNodeResponse', 'GetSliceSliverHMAC', 'GetNodeBWw',
117          'GetNodeRegion', 'SetNodeMEMy', 'GetNodeASType', 'SetNodePldistro',
118          'GetSliceArch', 'GetNodeCountry', 'SetSliceOmfControl', 'GetNodeHrn', 
119          'GetNodeCity', 'SetInterfaceAlias', 'GetNodeBW', 'GetNodePldistro',
120          'GetSlicePldistro', 'SetNodeASNumber', 'GetSliceHmac', 'SetSliceHmac',
121          'GetNodeMEM', 'GetNodeASNumber', 'GetInterfaceAlias', 'GetSliceVref',
122          'GetNodeArch', 'GetSliceSshKey', 'GetInterfaceKey4', 'GetInterfaceKey2',
123          'GetInterfaceKey3', 'GetInterfaceKey1', 'GetInterfaceBackdoor',
124          'GetInterfaceIfname', 'SetSliceSliverHMAC', 'SetNodeReliability',
125          'GetNodeCPU', 'SetPersonShowconf', 'SetNodeExtensions', 'SetNodeCPUy', 
126          'SetNodeCPUw', 'GetNodeResponsew', 'SetNodeResponsey', 'GetInterfaceSens',
127          'SetNodeResponsew', 'GetNodeResponsey', 'GetNodeReliability',
128          'GetNodeReliabilitym', 'SetNodeResponsem', 'SetInterfaceDriver',
129          'GetInterfaceSecurityMode', 'SetNodeDeployment', 'SetNodeReliabilitym',
130          'GetNodeFcdistro', 'SetInterfaceFreq', 'GetInterfaceNw',
131          'SetNodeReliabilityy', 'SetNodeReliabilityw', 'GetInterfaceIwconfig',
132          'SetSlicePldistro', 'SetSliceSshKey', 'GetNodeDeployment',
133          'GetInterfaceChannel', 'SetInterfaceKey2', 'SetInterfaceKey3',
134          'SetInterfaceKey1', 'SetInterfaceKey4'])
135      
136     _required_methods = set()
137
138     def __init__(self, username = None, password = None, session_key = None, 
139             proxy = None,
140             hostname = "www.planet-lab.eu",
141             urlpattern = "https://%(hostname)s:443/PLCAPI/",
142             local_peer = "PLE"):
143
144         self._blacklist = set()
145         self._reserved = set()
146         self._nodes_cache = None
147         self._already_cached = False
148
149         if session_key is not None:
150             self.auth = dict(AuthMethod='session', session=session_key)
151         elif username is not None and password is not None:
152             self.auth = dict(AuthMethod='password', Username=username, AuthString=password)
153         else:
154             self.auth = dict(AuthMethod='anonymous')
155         
156         self._local_peer = local_peer
157         self._url = urlpattern % {'hostname':hostname}
158
159         if (proxy is not None):
160             import urllib2
161             class HTTPSProxyTransport(xmlrpclib.Transport):
162                 def __init__(self, proxy, use_datetime=0):
163                     opener = urllib2.build_opener(urllib2.ProxyHandler({"https" : proxy}))
164                     xmlrpclib.Transport.__init__(self, use_datetime)
165                     self.opener = opener
166
167                 def request(self, host, handler, request_body, verbose=0):
168                     req = urllib2.Request('https://%s%s' % (host, handler), request_body)
169                     req.add_header('User-agent', self.user_agent)
170                     self.verbose = verbose
171                     return self.parse_response(self.opener.open(req))
172
173             self._proxy_transport = lambda : HTTPSProxyTransport(proxy)
174         else:
175             self._proxy_transport = lambda : None
176         
177         self.threadlocal = threading.local()
178     
179     @property
180     def api(self):
181         # Cannot reuse same proxy in all threads, py2.7 is not threadsafe
182         return xmlrpclib.ServerProxy(
183             self._url ,
184             transport = self._proxy_transport(),
185             allow_none = True)
186         
187     @property
188     def mcapi(self):
189         try:
190             return self.threadlocal.mc
191         except AttributeError:
192             return self.api
193         
194     def test(self):
195         import warnings
196         
197         # validate XMLRPC server checking supported API calls
198         methods = set(_retry(self.mcapi.system.listMethods)())
199         if self._required_methods - methods:
200             warnings.warn("Unsupported REQUIRED methods: %s" % (
201                 ", ".join(sorted(self._required_methods - methods)), ) )
202             return False
203
204         if self._expected_methods - methods:
205             warnings.warn("Unsupported EXPECTED methods: %s" % (
206                 ", ".join(sorted(self._expected_methods - methods)), ) )
207         
208         try:
209             # test authorization
210             network_types = _retry(self.mcapi.GetNetworkTypes)(self.auth)
211         except (xmlrpclib.ProtocolError, xmlrpclib.Fault),e:
212             warnings.warn(str(e))
213         
214         return True
215     
216     @property
217     def network_types(self):
218         try:
219             return self._network_types
220         except AttributeError:
221             self._network_types = _retry(self.mcapi.GetNetworkTypes)(self.auth)
222             return self._network_types
223     
224     @property
225     def peer_map(self):
226         try:
227             return self._peer_map
228         except AttributeError:
229             peers = _retry(self.mcapi.GetPeers)(self.auth, {}, ['shortname','peername','peer_id'])
230             
231             self._peer_map = dict(
232                 (peer['shortname'], peer['peer_id'])
233                 for peer in peers
234             )
235
236             self._peer_map.update(
237                 (peer['peername'], peer['peer_id'])
238                 for peer in peers
239             )
240
241             self._peer_map.update(
242                 (peer['peer_id'], peer['shortname'])
243                 for peer in peers
244             )
245
246             self._peer_map[None] = self._local_peer
247             return self._peer_map
248
249     def get_node_flavour(self, node):
250         """
251         Returns detailed information on a given node's flavour,
252         i.e. its base installation.
253
254         This depends on the global PLC settings in the PLC_FLAVOUR area,
255         optionnally overridden by any of the following tags if set on that node:
256         'arch', 'pldistro', 'fcdistro', 'deployment', 'extensions'
257         
258         Params:
259         
260             * node : int or string
261                 - int, Node identifier
262                 - string, Fully qualified hostname
263         
264         Returns:
265
266             struct
267                 * extensions : array of string, extensions to add to the base install
268                 * fcdistro : string, the fcdistro this node should be based upon
269                 * nodefamily : string, the nodefamily this node should be based upon
270                 * plain : boolean, use plain bootstrapfs image if set (for tests)  
271         """
272         if not isinstance(node, (str, int, long)):
273             raise ValueError, "Node must be either a non-unicode string or an int"
274         return _retry(self.mcapi.GetNodeFlavour)(self.auth, node)
275     
276     def get_nodes(self, node_id_or_name = None, fields = None, **kw):
277         """
278         Returns an array of structs containing details about nodes. 
279         If node_id_or_name is specified and is an array of node identifiers
280         or hostnames,  or the filters keyword argument with struct of node
281         attributes, or node attributes by keyword argument,
282         only nodes matching the filter will be returned.
283
284         If fields is specified, only the specified details will be returned. 
285         NOTE that if fields is unspecified, the complete set of native fields are
286         returned, which DOES NOT include tags at this time.
287
288         Some fields may only be viewed by admins.
289         
290         Special params:
291             
292             fields: an optional list of fields to retrieve. The default is all.
293             
294             filters: an optional mapping with custom filters, which is the only
295                 way to support complex filters like negation and numeric comparisons.
296                 
297             peer: a string (or sequence of strings) with the name(s) of peers
298                 to filter - or None for local nodes.
299         """
300         if fields is not None:
301             fieldstuple = (fields,)
302         else:
303             fieldstuple = ()
304
305         if node_id_or_name is not None:
306             return _retry(self.mcapi.GetNodes)(self.auth, node_id_or_name, *fieldstuple)
307         else:
308             filters = kw.pop('filters',{})
309             
310             if 'peer' in kw:
311                 peer = kw.pop('peer')
312                 
313                 name_to_id = self.peer_map.get
314                 
315                 if hasattr(peer, '__iter__'):
316                     # we can't mix local and external nodes, so
317                     # split and re-issue recursively in that case
318                     if None in peer or self._local_peer in peer:
319                         if None in peer:    
320                             peer.remove(None)
321
322                         if self._local_peer in peer:
323                             peer.remove(self._local_peer)
324
325                         return (
326                             self.get_nodes(node_id_or_name, fields,
327                                     filters = filters, peer=peer, **kw) + \
328                             self.get_nodes(node_id_or_name, fields, 
329                                     filters = filters, peer=None, **kw)
330                          )
331                     else:
332                         peer_filter = map(name_to_id, peer)
333
334                 elif peer is None or peer == self._local_peer:
335                     peer_filter = None
336                 else:
337                     peer_filter = name_to_id(peer)
338                 
339                 filters['peer_id'] = peer_filter
340             
341             filters.update(kw)
342
343             if not filters and not fieldstuple:
344                 if not self._nodes_cache and not self._already_cached:
345                     self._already_cached = True
346                     self._nodes_cache = _retry(self.mcapi.GetNodes)(self.auth)
347                 elif not self._nodes_cache:
348                     while not self._nodes_cache:
349                         time.sleep(10)
350                 return self._nodes_cache
351
352             return _retry(self.mcapi.GetNodes)(self.auth, filters, *fieldstuple)
353     
354     def get_node_tags(self, node_tag_id = None, fields = None, **kw):
355         if fields is not None:
356             fieldstuple = (fields,)
357         else:
358             fieldstuple = ()
359
360         if node_tag_id is not None:
361             return _retry(self.mcapi.GetNodeTags)(self.auth, node_tag_id,
362                     *fieldstuple)
363         else:
364             filters = kw.pop('filters',{})
365             filters.update(kw)
366             return _retry(self.mcapi.GetNodeTags)(self.auth, filters,
367                     *fieldstuple)
368
369     def get_slice_tags(self, slice_tag_id = None, fields = None, **kw):
370         if fields is not None:
371             fieldstuple = (fields,)
372         else:
373             fieldstuple = ()
374
375         if slice_tag_id is not None:
376             return _retry(self.mcapi.GetSliceTags)(self.auth, slice_tag_id,
377                     *fieldstuple)
378         else:
379             filters = kw.pop('filters',{})
380             filters.update(kw)
381             return _retry(self.mcapi.GetSliceTags)(self.auth, filters,
382                     *fieldstuple)
383     
384     def get_interfaces(self, interface_id_or_ip = None, fields = None, **kw):
385         if fields is not None:
386             fieldstuple = (fields,)
387         else:
388             fieldstuple = ()
389
390         if interface_id_or_ip is not None:
391             return _retry(self.mcapi.GetInterfaces)(self.auth,
392                     interface_id_or_ip, *fieldstuple)
393         else:
394             filters = kw.pop('filters',{})
395             filters.update(kw)
396             return _retry(self.mcapi.GetInterfaces)(self.auth, filters,
397                     *fieldstuple)
398         
399     def get_slices(self, slice_id_or_name = None, fields = None, **kw):
400         if fields is not None:
401             fieldstuple = (fields,)
402         else:
403             fieldstuple = ()
404
405         if slice_id_or_name is not None:
406             return _retry(self.mcapi.GetSlices)(self.auth, slice_id_or_name,
407                     *fieldstuple)
408         else:
409             filters = kw.pop('filters',{})
410             filters.update(kw)
411             return _retry(self.mcapi.GetSlices)(self.auth, filters,
412                     *fieldstuple)
413         
414     def update_slice(self, slice_id_or_name, **kw):
415         return _retry(self.mcapi.UpdateSlice)(self.auth, slice_id_or_name, kw)
416
417     def delete_slice_node(self, slice_id_or_name, node_id_or_hostname):
418         return _retry(self.mcapi.DeleteSliceFromNodes)(self.auth, slice_id_or_name, node_id_or_hostname)
419
420     def start_multicall(self):
421         self.threadlocal.mc = xmlrpclib.MultiCall(self.mcapi)
422     
423     def finish_multicall(self):
424         mc = self.threadlocal.mc
425         del self.threadlocal.mc
426         return _retry(mc)()
427
428     def get_slice_nodes(self, slicename):
429         return self.get_slices(slicename, ['node_ids'])[0]['node_ids']
430
431     def add_slice_nodes(self, slicename, nodes = None):
432         self.update_slice(slicename, nodes = nodes)
433
434     def get_node_info(self, node_id):
435         self.start_multicall()
436         info = self.get_nodes(node_id)
437         tags = self.get_node_tags(node_id=node_id, fields=('tagname','value'))
438         info, tags = self.finish_multicall()
439         return info, tags
440
441     def get_slice_id(self, slicename):
442         slice_id = None
443         slices = self.get_slices(slicename, fields=('slice_id',))
444         if slices:
445             slice_id = slices[0]['slice_id']
446
447         # If it wasn't found, don't remember this failure, keep trying
448         return slice_id
449
450     def get_slice_vnet_sys_tag(self, slicename):
451         slicetags = self.get_slice_tags(
452             name = slicename,
453             tagname = 'vsys_vnet',
454             fields=('value',))
455
456         if slicetags:
457             return slicetags[0]['value']
458         else:
459             return None
460
461     def blacklist_host(self, hostname):
462         self._blacklist.add(hostname)
463
464     def blacklisted(self):
465         return self._blacklist
466
467     def unblacklist_host(self, hostname):
468         del self._blacklist[hostname]
469
470     def reserve_host(self, hostname):
471         self._reserved.add(hostname)
472
473     def reserved(self):
474         return self._reserved
475
476     def unreserve_host(self, hostname):
477         del self._reserved[hostname]
478
479
480 class PLCAPIFactory(object):
481     """ 
482     .. note::
483
484         It allows PlanetLab RMs sharing a same slice, to use a same plcapi instance,
485         and to sincronize blacklisted and reserved hosts.
486
487     """
488     # use lock to avoid concurrent access to the Api list at the same times by 2 different threads
489     _lock = threading.Lock()
490     _apis = dict()
491
492     @classmethod 
493     def get_api(cls, pl_user, pl_pass, pl_host,
494             pl_ptn = "https://%(hostname)s:443/PLCAPI/",
495             proxy = None):
496         """ Get existing PLCAPI instance
497
498         :param pl_user: Planelab user name (used for web login)
499         :type pl_user: str
500         :param pl_pass: Planetlab password (used for web login)
501         :type pl_pass: str
502         :param pl_host: Planetlab registry host (e.g. "www.planet-lab.eu")
503         :type pl_host: str
504         :param pl_ptn: XMLRPC service pattern (e.g. https://%(hostname)s:443/PLCAPI/)
505         :type pl_ptn: str
506         :param proxy: Proxy service url
507         :type pl_ptn: str
508         """
509         if pl_user and pl_pass and pl_host:
510             key = cls._make_key(pl_user, pl_host)
511             with cls._lock:
512                 api = cls._apis.get(key)
513                 if not api:
514                     api = cls.create_api(pl_user, pl_pass, pl_host, pl_ptn, proxy)
515                 return api
516         return None
517
518     @classmethod 
519     def create_api(cls, pl_user, pl_pass, pl_host,
520             pl_ptn = "https://%(hostname)s:443/PLCAPI/",
521             proxy = None):
522         """ Create an PLCAPI instance
523
524         :param pl_user: Planelab user name (used for web login)
525         :type pl_user: str
526         :param pl_pass: Planetlab password (used for web login)
527         :type pl_pass: str
528         :param pl_host: Planetlab registry host (e.g. "www.planet-lab.eu")
529         :type pl_host: str
530         :param pl_ptn: XMLRPC service pattern (e.g. https://%(hostname)s:443/PLCAPI/)
531         :type pl_ptn: str
532         :param proxy: Proxy service url
533         :type pl_ptn: str
534         """
535         api = PLCAPI(
536             username = pl_user,
537             password = pl_pass,
538             hostname = pl_host,
539             urlpattern = pl_ptn,
540             proxy = proxy
541         )
542         key = cls._make_key(pl_user, pl_host)
543         cls._apis[key] = api
544         return api
545
546     @classmethod 
547     def _make_key(cls, *args):
548         """ Hash the credentials in order to create a key
549
550         :param args: list of arguments used to create the hash (user, host, port, ...)
551         :type args: list of args
552
553         """
554         skey = "".join(map(str, args))
555         return hashlib.md5(skey).hexdigest()
556