forked from shaba/openuds
Adding support for OpenNebula 5.0
This commit is contained in:
parent
6827380e99
commit
1578c92a88
@ -39,7 +39,7 @@ from . import on
|
||||
import pickle
|
||||
import logging
|
||||
|
||||
__updated__ = '2016-02-26'
|
||||
__updated__ = '2016-07-11'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -306,6 +306,7 @@ class LiveDeployment(UserDeployment):
|
||||
|
||||
return State.RUNNING
|
||||
except Exception as e:
|
||||
logger.exception('Got Exception')
|
||||
return self.__error(e)
|
||||
|
||||
# Queue execution methods
|
||||
|
@ -40,7 +40,7 @@ from uds.core.ui import gui
|
||||
|
||||
import logging
|
||||
|
||||
__updated__ = '2016-04-21'
|
||||
__updated__ = '2016-07-11'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -88,7 +88,7 @@ class LiveService(Service):
|
||||
# : Types of deploys (services in cache and/or assigned to users)
|
||||
deployedType = LiveDeployment
|
||||
|
||||
allowedProtocols = protocols.GENERIC + (protocols.SPICE,)
|
||||
allowedProtocols = protocols.GENERIC
|
||||
servicesTypeProvided = (serviceTypes.VDI,)
|
||||
|
||||
# Now the form part
|
||||
|
@ -48,7 +48,7 @@ import logging
|
||||
import six
|
||||
|
||||
# Python bindings for OpenNebula
|
||||
import oca
|
||||
# import oca
|
||||
|
||||
__updated__ = '2016-07-11'
|
||||
|
||||
@ -129,8 +129,9 @@ class Provider(ServiceProvider):
|
||||
@property
|
||||
def api(self):
|
||||
if self._api is None:
|
||||
self._api = oca.Client('{}:{}'.format(self.username.value, self.password.value), self.endpoint)
|
||||
self._api = on.OpenNebulaClient(self.username.value, self.password.value, self.endpoint)
|
||||
|
||||
logger.debug('Api: {}'.format(self._api))
|
||||
return self._api
|
||||
|
||||
def resetApi(self):
|
||||
@ -149,35 +150,13 @@ class Provider(ServiceProvider):
|
||||
'''
|
||||
|
||||
try:
|
||||
ver = self.api.version()
|
||||
if ver < '4.1' or ver >= '5':
|
||||
return [False, 'OpenNebula version is not supported (required version 4.x)']
|
||||
if self.api.version[0] < '4':
|
||||
return [False, 'OpenNebula version is not supported (required version 4.1 or newer)']
|
||||
except Exception as e:
|
||||
return [False, '{}'.format(e)]
|
||||
|
||||
return [True, _('Opennebula test connection passed')]
|
||||
|
||||
|
||||
def getMachines(self, force=False):
|
||||
'''
|
||||
Obtains the list of machines inside OpenNebula.
|
||||
Machines starting with UDS are filtered out
|
||||
|
||||
Args:
|
||||
force: If true, force to update the cache, if false, tries to first
|
||||
get data from cache and, if valid, return this.
|
||||
|
||||
Returns
|
||||
An array of dictionaries, containing:
|
||||
'name'
|
||||
'id'
|
||||
'cluster_id'
|
||||
'''
|
||||
vmpool = oca.VirtualMachinePool(self.api)
|
||||
vmpool.info()
|
||||
|
||||
return vmpool
|
||||
|
||||
def getDatastores(self, datastoreType=0):
|
||||
return on.storage.enumerateDatastores(self.api, datastoreType)
|
||||
|
||||
@ -204,12 +183,7 @@ class Provider(ServiceProvider):
|
||||
Returns:
|
||||
one of the on.VmState Values
|
||||
'''
|
||||
try:
|
||||
vm = oca.VirtualMachine.new_with_id(self.api, int(machineId))
|
||||
vm.info()
|
||||
return vm.state
|
||||
except Exception as e:
|
||||
logger.error('Error obtaining machine state for {} on opennebula: {}'.format(machineId, e))
|
||||
return on.vm.getMachineState(self.api, machineId)
|
||||
|
||||
|
||||
def startMachine(self, machineId):
|
||||
|
@ -38,12 +38,10 @@ import re
|
||||
import logging
|
||||
import six
|
||||
|
||||
from defusedxml import minidom
|
||||
import xmlrpclib
|
||||
from uds.core.util import xml2dict
|
||||
|
||||
# Python bindings for OpenNebula
|
||||
import oca
|
||||
|
||||
__updated__ = '2016-02-25'
|
||||
__updated__ = '2016-07-18'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -61,3 +59,218 @@ from . import template
|
||||
from . import vm
|
||||
from . import storage
|
||||
|
||||
# Decorator
|
||||
def ensureConnected(fnc):
|
||||
def inner(*args, **kwargs):
|
||||
args[0].connect()
|
||||
return fnc(*args, **kwargs)
|
||||
return inner
|
||||
|
||||
# Result checker
|
||||
def checkResult(lst, parseResult=True):
|
||||
if lst[0] == False:
|
||||
raise Exception('OpenNebula error {}: "{}"'.format(lst[2], lst[1]))
|
||||
if parseResult:
|
||||
return xml2dict.parse(lst[1])
|
||||
else:
|
||||
return lst[1]
|
||||
|
||||
def asList(element):
|
||||
if isinstance(element, (tuple, list)):
|
||||
return element
|
||||
return (element,)
|
||||
|
||||
class OpenNebulaClient(object):
|
||||
def __init__(self, username, password, endpoint):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.endpoint = endpoint
|
||||
self.connection = None
|
||||
self.cachedVersion = None
|
||||
|
||||
@property
|
||||
def sessionString(self):
|
||||
return '{}:{}'.format(self.username, self.password)
|
||||
|
||||
@property
|
||||
@ensureConnected
|
||||
def version(self):
|
||||
if self.cachedVersion is None:
|
||||
# Retrieve Version & keep it
|
||||
result = self.connection.one.system.version(self.sessionString)
|
||||
self.cachedVersion = checkResult(result, parseResult=False).split('.')
|
||||
return self.cachedVersion
|
||||
|
||||
|
||||
def connect(self):
|
||||
if self.connection is not None:
|
||||
return
|
||||
|
||||
self.connection = xmlrpclib.ServerProxy(self.endpoint)
|
||||
|
||||
@ensureConnected
|
||||
def enumStorage(self, storageType=0):
|
||||
storageType = six.text_type(storageType) # Ensure it is an string
|
||||
# Invoke datastore pools info, no parameters except connection string
|
||||
result = self.connection.one.datastorepool.info(self.sessionString)
|
||||
result = checkResult(result)
|
||||
for ds in asList(result['DATASTORE_POOL']['DATASTORE']):
|
||||
if ds['TYPE'] == storageType:
|
||||
yield(ds['ID'], ds['NAME'], ds['TOTAL_MB'], ds['FREE_MB'])
|
||||
|
||||
@ensureConnected
|
||||
def enumTemplates(self):
|
||||
'''
|
||||
Invoke templates pools info, with this parameters:
|
||||
1.- Session string
|
||||
2.- Filter flag - < = -3: Connected user’s resources - -2: All resources - -1: Connected user’s and his group’s resources - > = 0: UID User’s Resources
|
||||
3.- When the next parameter is >= -1 this is the Range start ID. Can be -1. For smaller values this is the offset used for pagination.
|
||||
4.- For values >= -1 this is the Range end ID. Can be -1 to get until the last ID. For values < -1 this is the page size used for pagination.
|
||||
'''
|
||||
result = self.connection.one.templatepool.info(self.sessionString, -3, -1, -1)
|
||||
result = checkResult(result)
|
||||
for ds in asList(result['VMTEMPLATE_POOL']['VMTEMPLATE']):
|
||||
yield(ds['ID'], ds['NAME'], ds['TEMPLATE']['MEMORY'])
|
||||
|
||||
@ensureConnected
|
||||
def enumImages(self):
|
||||
'''
|
||||
Invoke images pools info, with this parameters:
|
||||
1.- Session string
|
||||
2.- Filter flag - < = -3: Connected user’s resources - -2: All resources - -1: Connected user’s and his group’s resources - > = 0: UID User’s Resources
|
||||
3.- When the next parameter is >= -1 this is the Range start ID. Can be -1. For smaller values this is the offset used for pagination.
|
||||
4.- For values >= -1 this is the Range end ID. Can be -1 to get until the last ID. For values < -1 this is the page size used for pagination.
|
||||
'''
|
||||
result = self.connection.one.imagepool.info(self.sessionString, -3, -1, -1)
|
||||
result = checkResult(result)
|
||||
for ds in asList(result['IMAGE_POOL']['IMAGE']):
|
||||
yield(ds['ID'], ds['NAME'])
|
||||
|
||||
@ensureConnected
|
||||
def templateInfo(self, templateId, extraInfo=False):
|
||||
'''
|
||||
Returns a list
|
||||
first element is a dictionary (built from XML)
|
||||
second is original XML
|
||||
'''
|
||||
result = self.connection.one.template.info(self.sessionString, int(templateId), extraInfo)
|
||||
res = checkResult(result)
|
||||
return (res, result[1])
|
||||
|
||||
@ensureConnected
|
||||
def instantiateTemplate(self, templateId, vmName, createHold=False, templateToMerge='', privatePersistent=False):
|
||||
'''
|
||||
Instantiates a template (compatible with open nebula 4 & 5)
|
||||
1.- Session string
|
||||
2.- ID Of the template to instantiate
|
||||
3.- Name of the vm. If empty, open nebula will assign one
|
||||
4.- False to create machine on pending (default), True to create it on hold
|
||||
5.- A string containing an extra template to be merged with the one being instantiated. It can be empty. Syntax can be the usual attribute=value or XML.
|
||||
6.- true to create a private persistent copy of the template plus any image defined in DISK, and instantiate that copy.
|
||||
Note: This parameter is ignored on version 4, it is new for version 5.
|
||||
'''
|
||||
if self.version[0] == '4': # Version 4 has one less parameter than version 5
|
||||
result = self.connection.one.template.instantiate(self.sessionString, int(templateId), vmName, createHold, templateToMerge)
|
||||
else:
|
||||
result = self.connection.one.template.instantiate(self.sessionString, int(templateId), vmName, createHold, templateToMerge, privatePersistent)
|
||||
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def updateTemplate(self, templateId, template, updateType=0):
|
||||
'''
|
||||
Updates the template with the templateXml
|
||||
1.- Session string
|
||||
2.- Object ID (integer)
|
||||
3.- The new template contents. Syntax can be the usual attribute=value or XML.
|
||||
4.- Update type. 0 replace the whole template, 1 merge with the existing one
|
||||
'''
|
||||
result = self.connection.one.template.update(self.sessionString, int(templateId), template, int(updateType))
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def cloneTemplate(self, templateId, name):
|
||||
'''
|
||||
Clones the template
|
||||
'''
|
||||
if self.version[0] == '4':
|
||||
result = self.connection.one.template.clone(self.sessionString, int(templateId), name)
|
||||
else:
|
||||
result = self.connection.one.template.clone(self.sessionString, int(templateId), name, False) # This works as previous version clone
|
||||
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def deleteTemplate(self, templateId):
|
||||
'''
|
||||
'''
|
||||
result = self.connection.one.template.delete(self.sessionString, int(templateId))
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def cloneImage(self, srcId, name, datastoreId=-1):
|
||||
'''
|
||||
Clones the image.
|
||||
'''
|
||||
result = self.connection.one.image.clone(self.sessionString, int(srcId), name, int(datastoreId))
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def deleteImage(self, imageId):
|
||||
'''
|
||||
Deletes an image
|
||||
'''
|
||||
result = self.connection.one.image.delete(self.sessionString, int(imageId))
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def enumVMs(self):
|
||||
'''
|
||||
Invoke vm pools info, with this parameters:
|
||||
1.- Session string
|
||||
2.- Filter flag - < = -3: Connected user’s resources - -2: All resources - -1: Connected user’s and his group’s resources - > = 0: UID User’s Resources
|
||||
3.- When the next parameter is >= -1 this is the Range start ID. Can be -1. For smaller values this is the offset used for pagination.
|
||||
4.- For values >= -1 this is the Range end ID. Can be -1 to get until the last ID. For values < -1 this is the page size used for pagination.
|
||||
5.- VM state to filter by. (-2 = any state including DONE, -1 = any state EXCEPT DONE)
|
||||
'''
|
||||
result = self.connection.one.vmpool.info(self.sessionString, -3, -1, -1, -1)
|
||||
result = checkResult(result)
|
||||
for ds in asList(result['VM_POOL']['VM']):
|
||||
yield(ds['ID'], ds['NAME'])
|
||||
|
||||
@ensureConnected
|
||||
def VMInfo(self, vmId):
|
||||
'''
|
||||
Returns a list
|
||||
first element is a dictionary (built from XML)
|
||||
second is original XML
|
||||
'''
|
||||
result = self.connection.one.vm.info(self.sessionString, int(vmId))
|
||||
res = checkResult(result)
|
||||
return (res, result[1])
|
||||
|
||||
@ensureConnected
|
||||
def deleteVM(self, vmId):
|
||||
'''
|
||||
Deletes an vm
|
||||
'''
|
||||
if self.version[0] == '4':
|
||||
result = self.VMAction(vmId, 'delete')
|
||||
else:
|
||||
# Version 5
|
||||
result = self.VMAction(vmId, 'terminate-hard')
|
||||
|
||||
return checkResult(result, parseResult=False)
|
||||
|
||||
@ensureConnected
|
||||
def getVMState(self, vmId):
|
||||
'''
|
||||
Returns the VM State
|
||||
'''
|
||||
result = self.connection.one.vm.info(self.sessionString, int(vmId))
|
||||
return int(checkResult(result)['VM']['STATE'])
|
||||
|
||||
@ensureConnected
|
||||
def VMAction(self, vmId, action):
|
||||
result = self.connection.one.vm.action(self.sessionString, action, int(vmId))
|
||||
return checkResult(result, parseResult=False)
|
||||
|
@ -32,11 +32,9 @@
|
||||
'''
|
||||
|
||||
import logging
|
||||
import six
|
||||
import oca
|
||||
|
||||
|
||||
__updated__ = '2016-02-09'
|
||||
__updated__ = '2016-07-11'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -44,9 +42,4 @@ def enumerateDatastores(api, datastoreType=0):
|
||||
'''
|
||||
0 seems to be images datastore
|
||||
'''
|
||||
datastores = oca.DatastorePool(api)
|
||||
datastores.info()
|
||||
|
||||
for ds in datastores:
|
||||
if ds.type == datastoreType:
|
||||
yield (ds.id, ds.name)
|
||||
return api.enumStorage(datastoreType)
|
||||
|
@ -33,26 +33,21 @@
|
||||
|
||||
import logging
|
||||
import six
|
||||
import oca
|
||||
|
||||
from defusedxml import minidom
|
||||
# Python bindings for OpenNebula
|
||||
from .common import sanitizeName
|
||||
|
||||
__updated__ = '2016-02-09'
|
||||
__updated__ = '2016-07-18'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def getTemplates(api, force=False):
|
||||
logger.debug('Api: {}'.format(api))
|
||||
templatesPool = oca.VmTemplatePool(api)
|
||||
templatesPool.info()
|
||||
|
||||
for t in templatesPool:
|
||||
if t.name[:4] != 'UDSP':
|
||||
yield (t.id, t.name)
|
||||
|
||||
for t in api.enumTemplates():
|
||||
if t[1][:4] != 'UDSP': # 0 = id, 1 = name
|
||||
yield t
|
||||
|
||||
def create(api, fromTemplateId, name, toDataStore):
|
||||
'''
|
||||
@ -71,15 +66,14 @@ def create(api, fromTemplateId, name, toDataStore):
|
||||
'''
|
||||
try:
|
||||
# First, we clone the themplate itself
|
||||
templateId = api.call('template.clone', int(fromTemplateId), name)
|
||||
# templateId = api.call('template.clone', int(fromTemplateId), name)
|
||||
templateId = api.cloneTemplate(fromTemplateId, name)
|
||||
|
||||
# Now copy cloned images if possible
|
||||
try:
|
||||
imgs = oca.ImagePool(api)
|
||||
imgs.info()
|
||||
imgs = dict(((i.name, i.id) for i in imgs))
|
||||
imgs = dict(((i[1], i[0]) for i in api.enumImages()))
|
||||
|
||||
info = api.call('template.info', templateId)
|
||||
info = api.templateInfo(templateId)[1]
|
||||
template = minidom.parseString(info).getElementsByTagName('TEMPLATE')[0]
|
||||
logger.debug('XML: {}'.format(template.toxml()))
|
||||
|
||||
@ -102,14 +96,15 @@ def create(api, fromTemplateId, name, toDataStore):
|
||||
|
||||
# Now clone the image
|
||||
imgName = sanitizeName(name + ' DSK ' + six.text_type(counter))
|
||||
newId = api.call('image.clone', int(imgId), imgName, int(toDataStore))
|
||||
newId = api.cloneImage(imgId, imgName, toDataStore) # api.call('image.clone', int(imgId), imgName, int(toDataStore))
|
||||
if fromId is True:
|
||||
node.data = six.text_type(newId)
|
||||
else:
|
||||
node.data = imgName
|
||||
|
||||
# Now update the clone
|
||||
api.call('template.update', templateId, template.toxml())
|
||||
# api.call('template.update', templateId, template.toxml())
|
||||
api.updateTemplate(templateId, template.toxml())
|
||||
except Exception:
|
||||
logger.exception('Exception cloning image')
|
||||
|
||||
@ -128,11 +123,9 @@ def remove(api, templateId):
|
||||
# First, remove Images (wont be possible if there is any images already in use, but will try)
|
||||
# Now copy cloned images if possible
|
||||
try:
|
||||
imgs = oca.ImagePool(api)
|
||||
imgs.info()
|
||||
imgs = dict(((i.name, i.id) for i in imgs))
|
||||
imgs = dict(((i[1], i[0]) for i in api.enumImages()))
|
||||
|
||||
info = api.call('template.info', int(templateId))
|
||||
info = api.templateInfo(templateId)[1]
|
||||
template = minidom.parseString(info).getElementsByTagName('TEMPLATE')[0]
|
||||
logger.debug('XML: {}'.format(template.toxml()))
|
||||
|
||||
@ -148,12 +141,12 @@ def remove(api, templateId):
|
||||
logger.debug('Found {} for cloning'.format(imgId))
|
||||
|
||||
# Now delete the image
|
||||
api.call('image.delete', int(imgId))
|
||||
api.deleteImage(imgId) # api.call('image.delete', int(imgId))
|
||||
|
||||
except:
|
||||
logger.exception('Exception cloning image')
|
||||
|
||||
api.call('template.delete', int(templateId))
|
||||
api.deleteTemplate(templateId) # api.call('template.delete', int(templateId))
|
||||
except Exception as e:
|
||||
logger.error('Creating template on OpenNebula: {}'.format(e))
|
||||
|
||||
@ -165,13 +158,9 @@ def deployFrom(api, templateId, name):
|
||||
name: Name (sanitized) of the machine
|
||||
comments: Comments for machine
|
||||
templateId: Id of the template to deploy from
|
||||
clusterId: Id of the cluster to deploy to
|
||||
displayType: 'vnc' or 'spice'. Display to use ad OpenNebula admin interface
|
||||
memoryMB: Memory requested for machine, in MB
|
||||
guaranteedMB: Minimum memory guaranteed for this machine
|
||||
|
||||
Returns:
|
||||
Id of the machine being created form template
|
||||
'''
|
||||
vmId = api.call('template.instantiate', int(templateId), name, False, '')
|
||||
vmId = api.instantiateTemplate(templateId, name, False, '', False) # api.call('template.instantiate', int(templateId), name, False, '')
|
||||
return six.text_type(vmId)
|
||||
|
@ -33,7 +33,7 @@
|
||||
|
||||
import logging
|
||||
import six
|
||||
import oca
|
||||
# import oca
|
||||
|
||||
from defusedxml import minidom
|
||||
# Python bindings for OpenNebula
|
||||
@ -56,9 +56,10 @@ def getMachineState(api, machineId):
|
||||
one of the on.VmState Values
|
||||
'''
|
||||
try:
|
||||
vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
vm.info()
|
||||
return vm.state
|
||||
# vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
# vm.info()
|
||||
# return vm.state
|
||||
return api.getVMState(machineId)
|
||||
except Exception as e:
|
||||
logger.error('Error obtaining machine state for {} on opennebula: {}'.format(machineId, e))
|
||||
|
||||
@ -77,8 +78,9 @@ def startMachine(api, machineId):
|
||||
Returns:
|
||||
'''
|
||||
try:
|
||||
vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
vm.resume()
|
||||
# vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
# vm.resume()
|
||||
api.VMAction(machineId, 'resume')
|
||||
except Exception as e:
|
||||
logger.error('Error obtaining machine state for {} on opennebula: {}'.format(machineId, e))
|
||||
|
||||
@ -92,8 +94,9 @@ def stopMachine(api, machineId):
|
||||
Returns:
|
||||
'''
|
||||
try:
|
||||
vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
vm.poweroff_hard()
|
||||
# vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
# vm.poweroff_hard()
|
||||
api.VMAction(machineId, 'poweroff-hard')
|
||||
except Exception as e:
|
||||
logger.error('Error obtaining machine state for {} on opennebula: {}'.format(machineId, e))
|
||||
|
||||
@ -119,13 +122,14 @@ def removeMachine(api, machineId):
|
||||
Returns:
|
||||
'''
|
||||
try:
|
||||
vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
vm.delete()
|
||||
# vm = oca.VirtualMachine.new_with_id(api, int(machineId))
|
||||
# vm.delete()
|
||||
api.deleteVM(machineId)
|
||||
except Exception as e:
|
||||
logger.error('Error obtaining machine state for {} on opennebula: {}'.format(machineId, e))
|
||||
|
||||
|
||||
def enumerateMachines(self):
|
||||
def enumerateMachines(api):
|
||||
'''
|
||||
Obtains the list of machines inside OpenNebula.
|
||||
Machines starting with UDS are filtered out
|
||||
@ -140,18 +144,15 @@ def enumerateMachines(self):
|
||||
'id'
|
||||
'cluster_id'
|
||||
'''
|
||||
vmpool = oca.VirtualMachinePool(self.api)
|
||||
vmpool.info()
|
||||
|
||||
for vm in vmpool:
|
||||
yield (vm.id, vm.name)
|
||||
return api.enumVMs()
|
||||
|
||||
|
||||
def getNetInfo(api, machineId, networkId=None):
|
||||
'''
|
||||
Changes the mac address of first nic of the machine to the one specified
|
||||
'''
|
||||
md = minidom.parseString(api.call('vm.info', int(machineId)))
|
||||
# md = minidom.parseString(api.call('vm.info', int(machineId)))
|
||||
md = minidom.parseString(api.VMInfo(machineId)[1])
|
||||
node = md
|
||||
|
||||
for nic in md.getElementsByTagName('NIC'):
|
||||
@ -159,6 +160,7 @@ def getNetInfo(api, machineId, networkId=None):
|
||||
if networkId is None or int(netId) == int(networkId):
|
||||
node = nic
|
||||
break
|
||||
logger.debug(node.toxml())
|
||||
|
||||
# Default, returns first MAC found (or raise an exception if there is no MAC)
|
||||
try:
|
||||
|
Loading…
x
Reference in New Issue
Block a user