Skip to content
Open
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
4 changes: 4 additions & 0 deletions module/sources/vmware/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ def __init__(self):
This is done with a comma separated key = value list.
Can be set to "<NONE>" to not assign a scope type.
Note: this does not remove scope types from existing clusters in NetBox.
Note: cluster scopes require NetBox 4.2 or newer, the option is ignored
with older versions.
key: defines a cluster name as regex
value: defines the NetBox scope type name (use quotes if name contains commas)
""",
Expand All @@ -188,6 +190,8 @@ def __init__(self):
The scope id is the NetBox ID of the scope type.
This is done with a comma separated key = value list.
To be used in combination with the 'cluster_scope_type_relation'.
Note: cluster scopes require NetBox 4.2 or newer, the option is ignored
with older versions.
key: defines a cluster name as regex
value: defines the NetBox scope id (use quotes if name contains commas)
""",
Expand Down
26 changes: 22 additions & 4 deletions module/sources/vmware/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from module.common.support import normalize_mac_address
from module.netbox.inventory import NetBoxInventory
from module.netbox import *
from module.netbox.object_classes import NetBoxMappings

vsphere_automation_sdk_available = True
try:
Expand Down Expand Up @@ -2441,10 +2442,27 @@ def add_cluster(self, obj):
if version.parse(self.inventory.netbox_api_version) >= version.parse("4.2.0"):
# set the scope type and id if they are defined
if scope_type is not None:
data["scope_type"] = scope_type
data["scope_id"] = scope_id
log.debug(f"Cluster '{full_cluster_name}' (or {name}) has scope type '{scope_type}' "
f"and scope id '{scope_id}'.")
# the configured scope id is a NetBox id, the cluster needs the object it points to
scope_object = None
scope_classes = [NBSite, NBSiteGroup, NBLocation, NBRegion]
scope_class = NetBoxMappings().get(scope_type)
if scope_class not in scope_classes:
log.error(f"Unsupported scope type '{scope_type}' for cluster '{full_cluster_name}', must be "
f"one of: {', '.join(NetBoxMappings().scopes_object_types(scope_classes))}")
elif scope_id is None or not str(scope_id).strip().isdecimal():
log.error(f"Invalid scope id '{scope_id}' for cluster '{full_cluster_name}', "
f"must be the NetBox id of a {scope_class.name}")
else:
scope_object = self.inventory.get_by_id(scope_class, nb_id=int(scope_id))
if scope_object is None:
log.error(f"Unable to find {scope_class.name} with id '{scope_id}' in NetBox "
f"for cluster '{full_cluster_name}'")

if scope_object is not None:
data["scope_type"] = scope_type
data["scope_id"] = scope_object
log.debug(f"Cluster '{full_cluster_name}' (or {name}) has scope type '{scope_type}' "
f"and scope id '{scope_id}'.")
elif site_name is not None:
# NetBox wants the id of the scoped object, so the site has to be a real
# object here. A plain dict is sent as is and rejected with
Expand Down
8 changes: 5 additions & 3 deletions settings-example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,16 @@ password = super-secret
; This option defines the scope type for a cluster. The scope type can be 'dcim.site',
; 'dcim.sitegroup', 'dcim.location' or 'dcim.region'. This is done with a comma separated
; key = value list. Can be set to "<NONE>" to not assign a scope type. Note: this does not
; remove scope types from existing clusters in NetBox. key: defines a cluster name as
; remove scope types from existing clusters in NetBox. Note: cluster scopes require NetBox
; 4.2 or newer, the option is ignored with older versions. key: defines a cluster name as
; regex value: defines the NetBox scope type name (use quotes if name contains commas)
;cluster_scope_type_relation = Cluster_NYC = dcim.site, Cluster_FFM = dcim.sitegroup, Cluster_BER = dcim.location

; This option defines the scope id for a cluster. The scope id is the NetBox ID of the
; scope type. This is done with a comma separated key = value list. To be used in
; combination with the 'cluster_scope_type_relation'. key: defines a cluster name as regex
; value: defines the NetBox scope id (use quotes if name contains commas)
; combination with the 'cluster_scope_type_relation'. Note: cluster scopes require NetBox
; 4.2 or newer, the option is ignored with older versions. key: defines a cluster name as
; regex value: defines the NetBox scope id (use quotes if name contains commas)
;cluster_scope_id_relation = Cluster_NYC = 1, Cluster_FFM.* = 2, Cluster_BER = 7

; This option defines which cluster/host/VM belongs to which tenant.
Expand Down
68 changes: 67 additions & 1 deletion tests/test_cluster_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
id of the scoped object), and a scope read back from NetBox as an id has to resolve to
that object again, otherwise every run reports a change.
"""
from module.netbox.object_classes import NBCluster, NBClusterType, NBSite
import logging

import pytest

from module.netbox.object_classes import NBCluster, NBClusterType, NBSite, NBSiteGroup


def test_cluster_scope_is_resolved_from_an_id(inventory):
Expand Down Expand Up @@ -58,3 +62,65 @@ def test_source_scopes_a_cluster_with_the_site_object(vcsim, inventory, load_con
scope = cluster.data.get("scope_id")
assert isinstance(scope, NBSite), f"scope_id is {type(scope).__name__}, NetBox needs an object it can turn into an id"
assert scope.get_display_name() == "site1"



def sync_with_scope_relation(inventory, load_config, vmware_settings, scope_id, scope_type="dcim.sitegroup"):
from module.sources import instantiate_sources

load_config(vmware_settings + f"cluster_scope_type_relation = .* = {scope_type}\n"
f"cluster_scope_id_relation = .* = {scope_id}\n")
source = instantiate_sources()[0]
assert source.init_successful
inventory.resolve_relations()
source.apply()

clusters = list(inventory.get_all_items(NBCluster))
assert clusters, "no cluster was synced"
return clusters


def assert_only_errors(caplog, expected):
errors = [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR]
assert errors, f"no error logged, expected '{expected}'"
assert all(expected in message for message in errors), errors


def test_source_scopes_a_cluster_with_the_configured_scope_id(vcsim, inventory, load_config, vmware_settings):
"""The configured scope id is a NetBox id, the cluster has to carry the object it points to"""
site_group = inventory.add_object(NBSiteGroup, data={"name": "group1"}, read_from_netbox=True)
site_group.nb_id = 3

for cluster in sync_with_scope_relation(inventory, load_config, vmware_settings, 3):
assert cluster.data.get("scope_type") == "dcim.sitegroup"
assert cluster.data.get("scope_id") is site_group, \
f"scope_id is {cluster.data.get('scope_id')!r}, the configured id was not resolved"


@pytest.mark.parametrize("scope_id, error", [
("99", "Unable to find site group with id '99'"),
("group1", "Invalid scope id 'group1'"),
# str.isdigit() accepts it, int() does not
("\u00b3", "Invalid scope id '\u00b3'"),
], ids=["missing", "name", "superscript"])
def test_source_skips_an_invalid_scope_id(vcsim, inventory, load_config, vmware_settings, caplog, scope_id, error):
site_group = inventory.add_object(NBSiteGroup, data={"name": "group1"}, read_from_netbox=True)
site_group.nb_id = 3

clusters = sync_with_scope_relation(inventory, load_config, vmware_settings, scope_id)

for cluster in clusters:
assert cluster.data.get("scope_id") is None
assert cluster.data.get("scope_type") is None, "scope type was set without a scope"
assert_only_errors(caplog, error)


def test_source_skips_an_unsupported_scope_type(vcsim, inventory, load_config, vmware_settings, caplog):
"""netbox-sync knows 'dcim.device', but a cluster can't be scoped to it"""
clusters = sync_with_scope_relation(inventory, load_config, vmware_settings, 3, scope_type="dcim.device")

for cluster in clusters:
assert cluster.data.get("scope_id") is None
assert cluster.data.get("scope_type") is None
# without the check it ends in two 'Invalid data type' errors per cluster
assert_only_errors(caplog, "Unsupported scope type 'dcim.device'")
Loading