Various changes

- Added an option to specify an SSL CA certificates file to talk to the
  Netbox API
- Enhanced GPU expansion bays inventory: internal GPU (VGA) goes into
  the blade, external GPU (3D) goes into the expansion bay
- Unified the way expansion bays are managed (GPU and drive exansion
  bays)
- Started to refactor `network` module to make it more readable
- Dependencies in `setup.py` now reads its requirements from
  `requirements.txt` to avoid double maintenance
This commit is contained in:
Christophe Simon 2022-02-21 18:12:16 +01:00
parent 34c1619ce8
commit 305d4d41ec
10 changed files with 155 additions and 152 deletions

View file

@ -1,5 +1,4 @@
from packaging import version from packaging import version
import netbox_agent.dmidecode as dmidecode import netbox_agent.dmidecode as dmidecode
from netbox_agent.config import config from netbox_agent.config import config
from netbox_agent.config import netbox_instance as nb from netbox_agent.config import netbox_instance as nb
@ -39,11 +38,11 @@ def run(config):
print('netbox-agent is not compatible with Netbox prior to verison 2.9') print('netbox-agent is not compatible with Netbox prior to verison 2.9')
return False return False
if config.register or config.update_all or config.update_network or \
config.update_location or config.update_inventory or config.update_psu:
server.netbox_create_or_update(config)
if config.debug: if config.debug:
server.print_debug() server.print_debug()
if config.register or config.update_all or config.update_network or config.update_location or \
config.update_inventory or config.update_psu:
server.netbox_create_or_update(config)
return True return True

View file

@ -34,6 +34,7 @@ def get_config():
help='Manage blade expansions as external devices') help='Manage blade expansions as external devices')
p.add_argument('--log_level', default='debug') p.add_argument('--log_level', default='debug')
p.add_argument('--netbox.ssl_ca_certs_file', help='SSL CA certificates file')
p.add_argument('--netbox.url', help='Netbox URL') p.add_argument('--netbox.url', help='Netbox URL')
p.add_argument('--netbox.token', help='Netbox API Token') p.add_argument('--netbox.token', help='Netbox API Token')
p.add_argument('--netbox.ssl_verify', default=True, action='store_true', p.add_argument('--netbox.ssl_verify', default=True, action='store_true',
@ -80,8 +81,10 @@ def get_config():
return options return options
config = get_config()
def get_netbox_instance(): def get_netbox_instance():
config = get_config()
if config.netbox.url is None or config.netbox.token is None: if config.netbox.url is None or config.netbox.token is None:
logging.error('Netbox URL and token are mandatory') logging.error('Netbox URL and token are mandatory')
sys.exit(1) sys.exit(1)
@ -90,7 +93,12 @@ def get_netbox_instance():
url=get_config().netbox.url, url=get_config().netbox.url,
token=get_config().netbox.token, token=get_config().netbox.token,
) )
if get_config().netbox.ssl_verify is False: ca_certs_file = config.netbox.ssl_ca_certs_file
if ca_certs_file is not None:
session = requests.Session()
session.verify = ca_certs_file
nb.http_session = session
elif config.netbox.ssl_verify is False:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
session = requests.Session() session = requests.Session()
session.verify = False session.verify = False
@ -99,5 +107,4 @@ def get_netbox_instance():
return nb return nb
config = get_config()
netbox_instance = get_netbox_instance() netbox_instance = get_netbox_instance()

View file

@ -450,10 +450,11 @@ class Inventory():
if memory.get('serial') not in [x.serial for x in nb_memories]: if memory.get('serial') not in [x.serial for x in nb_memories]:
self.create_netbox_memory(memory) self.create_netbox_memory(memory)
def create_netbox_gpus(self): def create_netbox_gpus(self, gpus):
for gpu in self.lshw.get_hw_linux('gpu'): for gpu in gpus:
if 'product' in gpu and len(gpu['product']) > 50: if 'product' in gpu and len(gpu['product']) > 50:
gpu['product'] = (gpu['product'][:48] + '..') gpu['product'] = (gpu['product'][:48] + '..')
manufacturer = self.find_or_create_manufacturer(gpu["vendor"]) manufacturer = self.find_or_create_manufacturer(gpu["vendor"])
_ = nb.dcim.inventory_items.create( _ = nb.dcim.inventory_items.create(
device=self.device_id, device=self.device_id,
@ -461,26 +462,44 @@ class Inventory():
discovered=True, discovered=True,
tags=[{'name': INVENTORY_TAG['gpu']['name']}], tags=[{'name': INVENTORY_TAG['gpu']['name']}],
name=gpu['product'], name=gpu['product'],
description='GPU {}'.format(gpu['product']), description=gpu['description'],
) )
logging.info('Creating GPU model {}'.format(gpu['product'])) logging.info('Creating GPU model {}'.format(gpu['product']))
def is_external_gpu(self, gpu):
is_3d_gpu = gpu['description'].startswith('3D')
return self.server.is_blade() and \
self.server.own_gpu_expansion_slot() and is_3d_gpu
def do_netbox_gpus(self): def do_netbox_gpus(self):
gpus = self.lshw.get_hw_linux('gpu') gpus = []
gpu_models = {}
for gpu in self.lshw.get_hw_linux('gpu'):
# Filters GPU if an expansion bay is detected:
# The internal (VGA) GPU only goes into the blade inventory,
# the external (3D) GPU goes into the expansion blade.
if config.expansion_as_device and \
self.update_expansion ^ self.is_external_gpu(gpu):
continue
gpus.append(gpu)
gpu_models.setdefault(gpu["product"], 0)
gpu_models[gpu["product"]] += 1
nb_gpus = self.get_netbox_inventory( nb_gpus = self.get_netbox_inventory(
device_id=self.device_id, device_id=self.device_id,
tag=INVENTORY_TAG['gpu']['slug'], tag=INVENTORY_TAG['gpu']['slug'],
) )
nb_gpu_models = {}
if config.expansion_as_device and len(nb_gpus): for gpu in nb_gpus:
nb_gpu_models.setdefault(str(gpu), 0)
nb_gpu_models[str(gpu)] += 1
up_to_date = set(gpu_models) == set(nb_gpu_models)
if not gpus or not up_to_date:
for x in nb_gpus: for x in nb_gpus:
x.delete() x.delete()
elif not len(nb_gpus) or \ if gpus and not up_to_date:
len(nb_gpus) and len(gpus) != len(nb_gpus): self.create_netbox_gpus(gpus)
for x in nb_gpus:
x.delete()
self.create_netbox_gpus()
def create_or_update(self): def create_or_update(self):
if config.inventory is None or config.update_inventory is None: if config.inventory is None or config.update_inventory is None:

View file

@ -148,19 +148,19 @@ class Network(object):
if nic['mac'] is None: if nic['mac'] is None:
interface = self.nb_net.interfaces.get( interface = self.nb_net.interfaces.get(
name=nic['name'], name=nic['name'],
**self.custom_arg_id, **self.custom_arg_id
) )
else: else:
interface = self.nb_net.interfaces.get( interface = self.nb_net.interfaces.get(
mac_address=nic['mac'], mac_address=nic['mac'],
name=nic['name'], name=nic['name'],
**self.custom_arg_id, **self.custom_arg_id
) )
return interface return interface
def get_netbox_network_cards(self): def get_netbox_network_cards(self):
return self.nb_net.interfaces.filter( return self.nb_net.interfaces.filter(
**self.custom_arg_id, **self.custom_arg_id
) )
def get_netbox_type_for_nic(self, nic): def get_netbox_type_for_nic(self, nic):
@ -267,12 +267,12 @@ class Network(object):
nb_vlan = None nb_vlan = None
params = { params = dict(self.custom_arg)
params.update({
'name': nic['name'], 'name': nic['name'],
'type': type, 'type': type,
'mgmt_only': mgmt, 'mgmt_only': mgmt,
**self.custom_arg, })
}
if not nic.get('virtual', False): if not nic.get('virtual', False):
params['mac_address'] = nic['mac'] params['mac_address'] = nic['mac']
@ -338,59 +338,58 @@ class Network(object):
netbox_ip = nb.ipam.ip_addresses.create( netbox_ip = nb.ipam.ip_addresses.create(
**query_params **query_params
) )
else: return netbox_ip
netbox_ip = list(netbox_ips)[0]
# If IP exists in anycast
if netbox_ip.role and netbox_ip.role.label == 'Anycast':
logging.debug('IP {} is Anycast..'.format(ip))
unassigned_anycast_ip = [x for x in netbox_ips if x.interface is None]
assigned_anycast_ip = [x for x in netbox_ips if
x.interface and x.interface.id == interface.id]
# use the first available anycast ip
if len(unassigned_anycast_ip):
logging.info('Assigning existing Anycast IP {} to interface'.format(ip))
netbox_ip = unassigned_anycast_ip[0]
netbox_ip.interface = interface
netbox_ip.save()
# or if everything is assigned to other servers
elif not len(assigned_anycast_ip):
logging.info('Creating Anycast IP {} and assigning it to interface'.format(ip))
query_params = {
"address": ip,
"status": "active",
"role": self.ipam_choices['ip-address:role']['Anycast'],
"tenant": self.tenant.id if self.tenant else None,
"assigned_object_type": self.assigned_object_type,
"assigned_object_id": interface.id
}
netbox_ip = nb.ipam.ip_addresses.create(**query_params)
return netbox_ip
else:
if hasattr(netbox_ip, 'interface') and netbox_ip.interface is None or \
hasattr(netbox_ip, 'assigned_object') and netbox_ip.assigned_object is None:
logging.info('Assigning existing IP {ip} to {interface}'.format(
ip=ip, interface=interface))
elif hasattr(netbox_ip, 'interface') and \
netbox_ip.interface.id != interface.id or \
hasattr(netbox_ip, 'assigned_object') and \
netbox_ip.assigned_object_id != interface.id:
old_interface = netbox_ip.assigned_object netbox_ip = next(netbox_ips)
logging.info( # If IP exists in anycast
'Detected interface change for ip {ip}: old interface is ' if netbox_ip.role and netbox_ip.role.label == 'Anycast':
'{old_interface} (id: {old_id}), new interface is {new_interface} ' logging.debug('IP {} is Anycast..'.format(ip))
' (id: {new_id})' unassigned_anycast_ip = [x for x in netbox_ips if x.interface is None]
.format( assigned_anycast_ip = [x for x in netbox_ips if
old_interface=old_interface, new_interface=interface, x.interface and x.interface.id == interface.id]
old_id=netbox_ip.id, new_id=interface.id, ip=netbox_ip.address # use the first available anycast ip
)) if len(unassigned_anycast_ip):
else: logging.info('Assigning existing Anycast IP {} to interface'.format(ip))
return netbox_ip netbox_ip = unassigned_anycast_ip[0]
netbox_ip.interface = interface
netbox_ip.assigned_object_type = self.assigned_object_type
netbox_ip.assigned_object_id = interface.id
netbox_ip.save() netbox_ip.save()
return netbox_ip # or if everything is assigned to other servers
elif not len(assigned_anycast_ip):
logging.info('Creating Anycast IP {} and assigning it to interface'.format(ip))
query_params = {
"address": ip,
"status": "active",
"role": self.ipam_choices['ip-address:role']['Anycast'],
"tenant": self.tenant.id if self.tenant else None,
"assigned_object_type": self.assigned_object_type,
"assigned_object_id": interface.id
}
netbox_ip = nb.ipam.ip_addresses.create(**query_params)
return netbox_ip
else:
ip_interface = getattr(netbox_ip, 'interface', None)
assigned_object = getattr(netbox_ip, 'assigned_object', None)
if not ip_interface or not assigned_object:
logging.info('Assigning existing IP {ip} to {interface}'.format(
ip=ip, interface=interface))
elif (ip_interface and ip_interface.id != interface.id) or \
(assigned_object and assigned_object_id != interface.id):
old_interface = getattr(netbox_ip, "assigned_object", "n/a")
logging.info(
'Detected interface change for ip {ip}: old interface is '
'{old_interface} (id: {old_id}), new interface is {new_interface} '
' (id: {new_id})'
.format(
old_interface=old_interface, new_interface=interface,
old_id=netbox_ip.id, new_id=interface.id, ip=netbox_ip.address
))
else:
return netbox_ip
netbox_ip.assigned_object_type = self.assigned_object_type
netbox_ip.assigned_object_id = interface.id
netbox_ip.save()
def create_or_update_netbox_network_cards(self): def create_or_update_netbox_network_cards(self):
if config.update_all is None or config.update_network is None: if config.update_all is None or config.update_network is None:

View file

@ -25,7 +25,6 @@ class ServerBase():
self.bios = dmidecode.get_by_type(self.dmi, 'BIOS') self.bios = dmidecode.get_by_type(self.dmi, 'BIOS')
self.chassis = dmidecode.get_by_type(self.dmi, 'Chassis') self.chassis = dmidecode.get_by_type(self.dmi, 'Chassis')
self.system = dmidecode.get_by_type(self.dmi, 'System') self.system = dmidecode.get_by_type(self.dmi, 'System')
self.inventory = Inventory(server=self)
self.network = None self.network = None
@ -279,8 +278,10 @@ class ServerBase():
def _netbox_set_or_update_blade_slot(self, server, chassis, datacenter): def _netbox_set_or_update_blade_slot(self, server, chassis, datacenter):
# before everything check if right chassis # before everything check if right chassis
actual_device_bay = server.parent_device.device_bay if server.parent_device else None actual_device_bay = server.parent_device.device_bay \
actual_chassis = actual_device_bay.device if actual_device_bay else None if server.parent_device else None
actual_chassis = actual_device_bay.device \
if actual_device_bay else None
slot = self.get_blade_slot() slot = self.get_blade_slot()
if actual_chassis and \ if actual_chassis and \
actual_chassis.serial == chassis.serial and \ actual_chassis.serial == chassis.serial and \
@ -291,7 +292,11 @@ class ServerBase():
device_id=chassis.id, device_id=chassis.id,
name=slot, name=slot,
) )
if len(real_device_bays) > 0: real_device_bays = nb.dcim.device_bays.filter(
device_id=chassis.id,
name=slot,
)
if real_device_bays:
logging.info( logging.info(
'Setting device ({serial}) new slot on {slot} ' 'Setting device ({serial}) new slot on {slot} '
'(Chassis {chassis_serial})..'.format( '(Chassis {chassis_serial})..'.format(
@ -299,10 +304,14 @@ class ServerBase():
)) ))
# reset actual device bay if set # reset actual device bay if set
if actual_device_bay: if actual_device_bay:
# Forces the evaluation of the installed_device attribute to
# workaround a bug probably due to lazy loading optimization
# that prevents the value change detection
actual_device_bay.installed_device
actual_device_bay.installed_device = None actual_device_bay.installed_device = None
actual_device_bay.save() actual_device_bay.save()
# setup new device bay # setup new device bay
real_device_bay = real_device_bays[0] real_device_bay = next(real_device_bays)
real_device_bay.installed_device = server real_device_bay.installed_device = server
real_device_bay.save() real_device_bay.save()
else: else:
@ -324,7 +333,7 @@ class ServerBase():
device_id=chassis.id, device_id=chassis.id,
name=slot, name=slot,
) )
if len(real_device_bays) == 0: if not real_device_bays:
logging.error('Could not find slot {slot} expansion for chassis'.format( logging.error('Could not find slot {slot} expansion for chassis'.format(
slot=slot slot=slot
)) ))
@ -336,10 +345,14 @@ class ServerBase():
)) ))
# reset actual device bay if set # reset actual device bay if set
if actual_device_bay: if actual_device_bay:
# Forces the evaluation of the installed_device attribute to
# workaround a bug probably due to lazy loading optimization
# that prevents the value change detection
actual_device_bay.installed_device
actual_device_bay.installed_device = None actual_device_bay.installed_device = None
actual_device_bay.save() actual_device_bay.save()
# setup new device bay # setup new device bay
real_device_bay = real_device_bays[0] real_device_bay = next(real_device_bays)
real_device_bay.installed_device = expansion real_device_bay.installed_device = expansion
real_device_bay.save() real_device_bay.save()
@ -389,6 +402,7 @@ class ServerBase():
update_inventory = config.inventory and (config.register or update_inventory = config.inventory and (config.register or
config.update_all or config.update_inventory) config.update_all or config.update_inventory)
# update inventory if feature is enabled # update inventory if feature is enabled
self.inventory = Inventory(server=self)
if update_inventory: if update_inventory:
self.inventory.create_or_update() self.inventory.create_or_update()
# update psu # update psu
@ -417,12 +431,12 @@ class ServerBase():
# for every other specs # for every other specs
# check hostname # check hostname
if server.name != self.get_hostname(): if server.name != self.get_hostname():
update += 1
server.name = self.get_hostname() server.name = self.get_hostname()
update += 1
if sorted(set([x.name for x in server.tags])) != sorted(set(self.tags)): if sorted(set([x.name for x in server.tags])) != sorted(set(self.tags)):
update += 1
server.tags = [x.id for x in self.nb_tags] server.tags = [x.id for x in self.nb_tags]
update += 1
if config.update_all or config.update_location: if config.update_all or config.update_location:
ret, server = self.update_netbox_location(server) ret, server = self.update_netbox_location(server)
@ -458,3 +472,9 @@ class ServerBase():
print('NIC:',) print('NIC:',)
pprint(self.network.get_network_cards()) pprint(self.network.get_network_cards())
pass pass
def own_expansion_slot(self):
"""
Indicates if the device hosts an expansion card
"""
return False

View file

@ -86,10 +86,3 @@ class DellHost(ServerBase):
Expansion slot are always the compute bay number + 1 Expansion slot are always the compute bay number + 1
""" """
raise NotImplementedError raise NotImplementedError
def own_expansion_slot(self):
"""
Say if the device can host an extension card based
on the product name
"""
pass

View file

@ -21,29 +21,3 @@ class GenericHost(ServerBase):
def get_chassis_service_tag(self): def get_chassis_service_tag(self):
return self.get_service_tag() return self.get_service_tag()
def get_expansion_product(self):
"""
Get the extension slot that is on a pair slot number
next to the compute slot that is on an odd slot number
"""
raise NotImplementedError
def is_expansion_slot(self, server):
"""
Return True if its an extension slot
"""
raise NotImplementedError
def get_blade_expansion_slot(self):
"""
Expansion slot are always the compute bay number + 1
"""
raise NotImplementedError
def own_expansion_slot(self):
"""
Say if the device can host an extension card based
on the product name
"""
pass

View file

@ -1,5 +1,6 @@
import netbox_agent.dmidecode as dmidecode import netbox_agent.dmidecode as dmidecode
from netbox_agent.server import ServerBase from netbox_agent.server import ServerBase
from netbox_agent.inventory import Inventory
class HPHost(ServerBase): class HPHost(ServerBase):
@ -92,24 +93,28 @@ class HPHost(ServerBase):
def own_expansion_slot(self): def own_expansion_slot(self):
""" """
Say if the device can host an extension card based Indicates if the device hosts an expension card
on the product name
""" """
return self.own_gpu_expansion_slot() or self.own_disk_expansion_slot() return self.own_gpu_expansion_slot() or self.own_disk_expansion_slot()
def own_gpu_expansion_slot(self): def own_gpu_expansion_slot(self):
""" """
Say if the device can host an extension card based Indicates if the device hosts a GPU expansion card based
on the product name on the product name
""" """
return self.get_product_name().endswith('Graphics Exp') return self.get_product_name().endswith('Graphics Exp')
def own_disk_expansion_slot(self): def own_disk_expansion_slot(self):
""" """
Say if the device can host an extension card based Indicates if the device hosts a drive expansion card based
on the product name on raid card attributes.
""" """
for raid_card in self.inventory.get_raid_cards(): # Uses already parsed inventory if available
# parses it otherwise
inventory = getattr(self, "inventory", None)
if inventory is None:
inventory = Inventory(self)
for raid_card in inventory.get_raid_cards():
if self.is_blade() and raid_card.is_external(): if self.is_blade() and raid_card.is_external():
return True return True
return False return False

View file

@ -77,22 +77,3 @@ class SupermicroHost(ServerBase):
I only know on model of slot GPU extension card that. I only know on model of slot GPU extension card that.
""" """
raise NotImplementedError raise NotImplementedError
def is_expansion_slot(self, server):
"""
Return True if its an extension slot, based on the name
"""
raise NotImplementedError
def get_blade_expansion_slot(self):
"""
Expansion slot are always the compute bay number + 1
"""
raise NotImplementedError
def own_expansion_slot(self):
"""
Say if the device can host an extension card based
on the product name
"""
pass

View file

@ -1,8 +1,22 @@
from setuptools import find_packages, setup from setuptools import find_packages, setup
import os
def get_requirements():
reqs_path = os.path.join(
os.path.dirname(__file__),
'requirements.txt'
)
with open(reqs_path, 'r') as f:
reqs = [
r.strip() for r in f
if r.strip()
]
return reqs
setup( setup(
name='netbox_agent', name='netbox_agent',
version='0.6.2', version='0.6.3',
description='NetBox agent for server', description='NetBox agent for server',
long_description=open('README.md', encoding="utf-8").read(), long_description=open('README.md', encoding="utf-8").read(),
long_description_content_type='text/markdown', long_description_content_type='text/markdown',
@ -13,15 +27,7 @@ setup(
include_package_data=True, include_package_data=True,
packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]),
use_scm_version=True, use_scm_version=True,
install_requires=[ install_requires=get_requirements(),
'pynetbox==6.1.2',
'netaddr==0.8.0',
'netifaces==0.10.9',
'pyyaml==5.4.1',
'jsonargparse==2.32.2',
'python-slugify==5.0.2',
'packaging==20.9',
],
zip_safe=False, zip_safe=False,
keywords=['netbox'], keywords=['netbox'],
classifiers=[ classifiers=[