Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 0 additions & 41 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,6 @@ jobs:
with:
string: ${{ github.repository }}

- name: set dockerhub repo name
uses: mad9000/actions-find-and-replace-string@5
id: dockerhub_image
with:
source: ${{ steps.docker_image.outputs.lowercase }}
find: 'bb-ricardo'
replace: ${{ vars.DOCKER_HUB_USERNAME }}

- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
Expand All @@ -71,36 +63,3 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}

- name: Docker hub meta
id: meta_dhub
uses: docker/metadata-action@v5
with:
images: docker.io/${{ steps.dockerhub_image.outputs.value }}
tags: |
type=semver,pattern={{version}}
type=ref,event=branch

- name: Build and push with tags - ${{ steps.dockerhub_image.outputs.value }}
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta_dhub.outputs.tags }}
labels: ${{ steps.meta_dhub.outputs.labels }}

# - name: push README to Dockerhub
# uses: christian-korneck/update-container-description-action@v1
# env:
# DOCKER_USER: ${{ vars.DOCKER_HUB_USERNAME }}
# DOCKER_PASS: ${{ secrets.DOCKER_HUB_PASSWORD }}
# with:
# destination_container_repo: ${{ steps.dockerhub_image.outputs.value }}
# provider: dockerhub
4 changes: 2 additions & 2 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
* @semx @bb-ricardo


/sources/hetzner/ @SerhiiZahuba
docs/source_hetzner.md @SerhiiZahuba
/module/sources/hetzner/ @SerhiiZahuba
/docs/source_hetzner.md @SerhiiZahuba
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ usage: netbox-sync.py [-h] [-c settings.ini [settings.ini ...]] [-g]

Sync objects from various sources to NetBox

Version: 1.9.0 (2026-10-02)
Version: 1.9.1 (2026-10-05)
Project URL: https://github.com/bb-ricardo/netbox-sync

options:
Expand Down
4 changes: 2 additions & 2 deletions module/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
# For a copy, see file LICENSE.txt included in this
# repository or visit: <https://opensource.org/licenses/MIT>.

__version__ = "1.9.0"
__version_date__ = "2026-10-02"
__version__ = "1.9.1"
__version_date__ = "2026-10-05"
__author__ = "Ricardo Bartels <ricardo.bartels@telekom.de>"
__description__ = "NetBox Sync"
__license__ = "MIT"
Expand Down
6 changes: 2 additions & 4 deletions module/netbox/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,7 @@ def query_current_data(self, netbox_objects_to_query=None):
cache_this_class = False

if self.testing_cache is True and len(cached_nb_data) > 0:
for object_data in cached_nb_data:
self.inventory.add_object(nb_object_class, data=object_data, read_from_netbox=True)
self.inventory.load_from_netbox(nb_object_class, cached_nb_data)

# mark this object class as retrieved
self.resolved_dependencies.add(nb_object_class)
Expand Down Expand Up @@ -561,8 +560,7 @@ def query_current_data(self, netbox_objects_to_query=None):

log.debug(f"Processing %s returned {nb_object_class.name}%s" % (len(nb_objects), plural(len(nb_objects))))

for object_data in nb_objects:
self.inventory.add_object(nb_object_class, data=object_data, read_from_netbox=True)
self.inventory.load_from_netbox(nb_object_class, nb_objects)

# mark this object class as retrieved
self.resolved_dependencies.add(nb_object_class)
Expand Down
20 changes: 20 additions & 0 deletions module/netbox/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def __new__(cls):

def init(self):

# object types read from NetBox in this run
self.loaded_types = set()

for object_type in NetBoxObject.__subclasses__():

self.base_structure[object_type.name] = list()
Expand Down Expand Up @@ -211,6 +214,23 @@ def add_object(self, object_type, data=None, read_from_netbox=False, source=None

return new_object

def load_from_netbox(self, object_type, objects_data):
"""
Adds all objects of $object_type read from NetBox to the inventory.

Parameters
----------
object_type: NetBoxObject subclass
object type which was read from NetBox
objects_data: list
data of every object of this type NetBox returned
"""

for object_data in objects_data:
self.add_object(object_type, data=object_data, read_from_netbox=True)

self.loaded_types.add(object_type)

def add_update_object(self, object_type, data=None, read_from_netbox=False, source=None):
"""
Adds new object or updates existing object with data, based on the content of data.
Expand Down
11 changes: 9 additions & 2 deletions module/netbox/object_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,8 @@ def resolve_relations(self):

if resolved_data is not None:
self.data[key] = resolved_data
else:
# a type this run did not read from NetBox is expected to be unresolvable, keep the NetBox value
elif data_type in self.inventory.loaded_types:
log.error(f"Problems resolving relation '{key}' for object '{self.get_display_name()}' and "
f"value '{data_value}'")

Expand Down Expand Up @@ -1727,7 +1728,8 @@ def resolve_relations(self):

def matches_site(self, site=None) -> bool:
"""
tries to figure out if this prefix matches a certain site or site group
tries to figure out if this prefix matches a certain site or site group.
Without a site only a prefix without a scope matches.

Parameters
----------
Expand All @@ -1739,6 +1741,11 @@ def matches_site(self, site=None) -> bool:
bool: True if matches one of the params

"""
if site is None:
# a scope whose object is not in the inventory resolves to None as well
return not self.data.get("scope_type") and self.data.get("scope_id") is None and \
self.data.get("site") is None

if isinstance(site, NBSite):
if isinstance(self.data.get("scope_id"), NBSite) and self.data.get("scope_id") == site:
return True
Expand Down
9 changes: 9 additions & 0 deletions module/sources/check_redfish/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ def __init__(self):
syncing.""",
default_value=False),

ConfigOption("skip_os_reported_ips",
bool,
description="""define if IP addresses which the BMC reports for the ports of the server
itself are skipped. Some BMCs (i.e. HPE iLO with AMS) report the IP addresses of the
operating system on the physical NICs. If the operating system is synced by another
source (i.e. VMware ESXi hosts) these IPs would be moved back and forth between
interfaces. If True only the IP addresses of the BMC ports are synced.""",
default_value=False),

ConfigOption(**config_option_ip_tenant_inheritance_order_definition),
]

Expand Down
3 changes: 3 additions & 0 deletions module/sources/check_redfish/import_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,9 @@ def update_network_interface(self):

# collect ip addresses
nic_ips[port_name] = list()
if self.settings.skip_os_reported_ips is True and mgmt_only is False:
continue

for ipv4_address in grab(nic_port, "ipv4_addresses", fallback=list()):
if self.settings.permitted_subnets.permitted(ipv4_address, interface_name=port_name) is False:
continue
Expand Down
7 changes: 6 additions & 1 deletion module/sources/common/source_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,12 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac

# grab current data
for interface in self.inventory.get_all_interfaces(device_vm_object):
int_mac = grab(interface, "data.mac_address")
# from NetBox 4.2 on 'mac_address' is a read-only leftover of the last NetBox read which sources
# don't update. Use the current primary MAC address object instead.
if version.parse(self.inventory.netbox_api_version) >= version.parse("4.2.0"):
int_mac = grab(grab(interface, "data.primary_mac_address"), "data.mac_address")
else:
int_mac = grab(interface, "data.mac_address")
int_name = grab(interface, "data.name")

if interface_exclude_filter is not None and int_name is not None and \
Expand Down
6 changes: 3 additions & 3 deletions module/sources/vmware/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2433,10 +2433,10 @@ def add_host(self, obj):
pnic_hints = dict()
# noinspection PyBroadException
try:
for hint in obj.configManager.networkSystem.QueryNetworkHint(""):
for hint in obj.configManager.networkSystem.QueryNetworkHint():
pnic_hints[hint.device] = hint
except Exception:
pass
except Exception as e:
log.debug(f"Unable to query network hints (CDP/LLDP) of host '{name}': {e}")

pnic_list = grab(obj, "config.network.pnic", fallback=list())
if self.settings.skip_host_nics is True:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "netbox-sync"
version = "1.9.0"
version = "1.9.1"
description = "Sync objects from various sources to NetBox"
authors = [
{ name = "Ricardo Bartels", email = "ricardo.bartels@telekom.de" }
Expand Down
9 changes: 8 additions & 1 deletion settings-example.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
;;; Welcome to the NetBox Sync configuration file.
;;; Version: 1.9.0 (2026-10-02)
;;; Version: 1.9.1 (2026-10-05)
;;; Project URL: https://github.com/bb-ricardo/netbox-sync

; The values in this file override the default values used by the system if a config
Expand Down Expand Up @@ -584,6 +584,13 @@ inventory_file_path = /full/path/to/inventory/files
; incorrect syncing.
;skip_fhrp_group_ips = False

; define if IP addresses which the BMC reports for the ports of the server itself are
; skipped. Some BMCs (i.e. HPE iLO with AMS) report the IP addresses of the operating
; system on the physical NICs. If the operating system is synced by another source (i.e.
; VMware ESXi hosts) these IPs would be moved back and forth between interfaces. If True
; only the IP addresses of the BMC ports are synced.
;skip_os_reported_ips = False

; define in which order the IP address tenant will be assigned if tenant is undefined.
; possible values:
; * device : host or VM tenant will be assigned to the IP address
Expand Down
50 changes: 50 additions & 0 deletions tests/test_check_redfish_interface_ips.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
NBIPAddress objects.
"""

from module.common.misc import grab
from module.netbox.object_classes import NBInterface, NBIPAddress
from module.sources.common.permitted_subnets import PermittedSubnets


def seed_interface_with_ip(context, name="pnet0", address="172.10.10.12/24"):
Expand Down Expand Up @@ -71,3 +73,51 @@ def test_ip_is_still_removed_when_the_discovered_ips_are_unusable(check_redfish_
keep_undiscovered_ips=True)

assert "assigned_object_id" in ip.unset_items


def run_network_interface_sync(check_redfish_source, **settings):
"""Sync a server NIC port and a BMC port which both report IP addresses, like an HPE iLO
with AMS which reports the IPs of the operating system on the physical NICs."""

context = check_redfish_source(**settings)
source = context.source
context.inventory.netbox_api_version = "4.2.0"
source.interface_adapter_type_dict = {}
source.manager_name = "iLO 5"
source.settings.permitted_subnets = PermittedSubnets("10.0.0.0/8")
source.settings.ip_tenant_inheritance_order = []

source.inventory_file_content = {
"inventory": {
"network_port": [
{"id": "2.2", "addresses": ["8C:DC:D4:0F:D7:84"],
"operation_status": "Enabled", "link_status": "Up", "manager_ids": [],
"ipv4_addresses": ["10.40.232.163/22"], "ipv6_addresses": []},
{"id": "1:1", "name": "Manager Dedicated Network Interface",
"addresses": ["08:F1:EA:93:F3:D6"], "operation_status": "Enabled",
"link_status": "Up", "manager_ids": ["1"],
"ipv4_addresses": ["10.44.102.102/22"], "ipv6_addresses": []},
],
}
}

source.update_network_interface()

return {grab(ip, "data.assigned_object_id.data.name"): str(grab(ip, "data.address"))
for ip in context.inventory.get_all_items(NBIPAddress)}


def test_os_reported_ips_are_synced_by_default(check_redfish_source):
"""Without skip_os_reported_ips the IPs of all ports are synced, as before."""

assigned_ips = run_network_interface_sync(check_redfish_source)

assert assigned_ips == {"2.2": "10.40.232.163/22", "iLO 5 (1:1)": "10.44.102.102/22"}


def test_os_reported_ips_are_skipped(check_redfish_source):
"""With skip_os_reported_ips only the IPs of the BMC ports are synced."""

assigned_ips = run_network_interface_sync(check_redfish_source, skip_os_reported_ips=True)

assert assigned_ips == {"iLO 5 (1:1)": "10.44.102.102/22"}
56 changes: 56 additions & 0 deletions tests/test_interface_matching_primary_mac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020 - 2026 netbox-sync team. All rights reserved.
#
# netbox-sync.py
#
# This work is licensed under the terms of the MIT license.
# For a copy, see file LICENSE.txt included in this
# repository or visit: <https://opensource.org/licenses/MIT>.

"""Interface matching by MAC must use the current primary MAC address object (NetBox >= 4.2).

The legacy interface 'mac_address' field is only what NetBox returned at read time. Once a
previous source moved the primary MAC to another address, matching on the stale value merged
a BMC port (i.e. Lenovo XCC) into the ESXi vmk0 interface on every run.
"""

from module.netbox.object_classes import NBInterface, NBMACAddress

BMC_MAC = "38:68:DD:34:33:C5"
VMK_MAC = "00:50:56:67:39:DA"


def seed_vmk0(context, legacy_mac, primary_mac):
interface = context.inventory.add_object(
NBInterface, data={"name": "vmk0", "device": context.device, "type": "virtual", "mac_address": legacy_mac},
source=context.source)
mac_object = context.inventory.add_object(
NBMACAddress, data={"mac_address": primary_mac, "assigned_object_id": interface,
"assigned_object_type": NBInterface}, source=context.source)
interface.update(data={"primary_mac_address": mac_object}, source=context.source)
return interface


def bmc_port():
return {"XCC (1:NIC:abc)": {"name": "XCC (1:NIC:abc)", "mac_address": BMC_MAC, "type": "other",
"mgmt_only": True}}


def test_bmc_port_ignores_stale_legacy_mac(check_redfish_source):

context = check_redfish_source()
seed_vmk0(context, legacy_mac=BMC_MAC, primary_mac=VMK_MAC)

data = context.source.map_object_interfaces_to_current_interfaces(context.device, bmc_port(), True)

assert data["XCC (1:NIC:abc)"] is None


def test_bmc_port_still_matches_by_primary_mac(check_redfish_source):

context = check_redfish_source()
vmk0 = seed_vmk0(context, legacy_mac=VMK_MAC, primary_mac=BMC_MAC)

data = context.source.map_object_interfaces_to_current_interfaces(context.device, bmc_port(), True)

assert data["XCC (1:NIC:abc)"] is vmk0
Loading
Loading