Skip to content

Commit b31da2f

Browse files
authored
Full locations functionality in inventory plugin for 2.11 (#510)
1 parent 8540c86 commit b31da2f

13 files changed

+520
-540
lines changed

plugins/inventory/nb_inventory.py

Lines changed: 163 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,15 @@
109109
type: boolean
110110
version_added: "0.2.1"
111111
group_by:
112-
description: Keys used to create groups. The I(plurals) option controls which of these are valid.
112+
description:
113+
- Keys used to create groups. The I(plurals) option controls which of these are valid.
114+
- I(rack_group) is supported on NetBox versions 2.10 or lower only
115+
- I(location) is supported on NetBox versions 2.11 or higher only
113116
type: list
114117
choices:
115118
- sites
116119
- site
120+
- location
117121
- tenants
118122
- tenant
119123
- racks
@@ -412,7 +416,6 @@ def group_extractors(self):
412416
self._pluralize_group_by("site"): self.extract_site,
413417
self._pluralize_group_by("tenant"): self.extract_tenant,
414418
self._pluralize_group_by("rack"): self.extract_rack,
415-
"rack_group": self.extract_rack_group,
416419
"rack_role": self.extract_rack_role,
417420
self._pluralize_group_by("tag"): self.extract_tags,
418421
self._pluralize_group_by("role"): self.extract_device_role,
@@ -421,6 +424,16 @@ def group_extractors(self):
421424
self._pluralize_group_by("manufacturer"): self.extract_manufacturer,
422425
}
423426

427+
# Locations were added in 2.11 replacing rack-groups.
428+
if self.api_version >= version.parse("2.11"):
429+
extractors.update(
430+
{"location": self.extract_location,}
431+
)
432+
else:
433+
extractors.update(
434+
{"rack_group": self.extract_rack_group,}
435+
)
436+
424437
if self.services:
425438
extractors.update(
426439
{"services": self.extract_services,}
@@ -704,6 +717,25 @@ def extract_regions(self, host):
704717
object_parent_lookup=self.regions_parent_lookup,
705718
)
706719

720+
def extract_location(self, host):
721+
# A host may have a location. A location may have a parent location.
722+
# Produce a list of locations:
723+
# - it will be empty if the device has no location
724+
# - it will have 1 element if the device's location has no parent
725+
# - it will have multiple elements if the location has a parent location
726+
727+
try:
728+
location_id = host["location"]["id"]
729+
except (KeyError, TypeError):
730+
# Device has no location
731+
return []
732+
733+
return self._objects_array_following_parents(
734+
initial_object_id=location_id,
735+
object_lookup=self.locations_lookup,
736+
object_parent_lookup=self.locations_parent_lookup,
737+
)
738+
707739
def extract_cluster(self, host):
708740
try:
709741
# cluster does not have a slug
@@ -787,6 +819,35 @@ def get_region_parent(region):
787819
filter(lambda x: x is not None, map(get_region_parent, regions))
788820
)
789821

822+
def refresh_locations_lookup(self):
823+
# Locations were added in v2.11. Return empty lookups for previous versions.
824+
if self.api_version < version.parse("2.11"):
825+
return
826+
827+
url = self.api_endpoint + "/api/dcim/locations/?limit=0"
828+
locations = self.get_resource_list(api_url=url)
829+
self.locations_lookup = dict(
830+
(location["id"], location["slug"]) for location in locations
831+
)
832+
833+
def get_location_parent(location):
834+
# Will fail if location does not have a parent location
835+
try:
836+
return (location["id"], location["parent"]["id"])
837+
except Exception:
838+
return (location["id"], None)
839+
840+
def get_location_site(location):
841+
# Locations MUST be assigned to a site
842+
return (location["id"], location["site"]["id"])
843+
844+
# Dictionary of location id to parent location id
845+
self.locations_parent_lookup = dict(
846+
filter(None, map(get_location_parent, locations))
847+
)
848+
# Location to site lookup
849+
self.locations_site_lookup = dict(map(get_location_site, locations))
850+
790851
def refresh_tenants_lookup(self):
791852
url = self.api_endpoint + "/api/tenancy/tenants/?limit=0"
792853
tenants = self.get_resource_list(api_url=url)
@@ -813,16 +874,11 @@ def get_role_for_rack(rack):
813874
self.racks_role_lookup = dict(map(get_role_for_rack, racks))
814875

815876
def refresh_rack_groups_lookup(self):
877+
# Locations were added in v2.11 replacing rack groups. Do nothing for 2.11+
816878
if self.api_version >= version.parse("2.11"):
817-
# In NetBox v2.11 Breaking Changes:
818-
# The RackGroup model has been renamed to Location
819-
# (see netbox-community/netbox#4971).
820-
# Its REST API endpoint has changed from /api/dcim/rack-groups/
821-
# to /api/dcim/locations/
822-
# https://netbox.readthedocs.io/en/stable/release-notes/#v2110-2021-04-16
823-
url = self.api_endpoint + "/api/dcim/locations/?limit=0"
824-
else:
825-
url = self.api_endpoint + "/api/dcim/rack-groups/?limit=0"
879+
return
880+
881+
url = self.api_endpoint + "/api/dcim/rack-groups/?limit=0"
826882
rack_groups = self.get_resource_list(api_url=url)
827883
self.rack_groups_lookup = dict(
828884
(rack_group["id"], rack_group["slug"]) for rack_group in rack_groups
@@ -1054,6 +1110,7 @@ def lookup_processes(self):
10541110
lookups = [
10551111
self.refresh_sites_lookup,
10561112
self.refresh_regions_lookup,
1113+
self.refresh_locations_lookup,
10571114
self.refresh_tenants_lookup,
10581115
self.refresh_racks_lookup,
10591116
self.refresh_rack_groups_lookup,
@@ -1288,26 +1345,18 @@ def generate_group_name(self, grouping, group):
12881345
return "_".join([grouping, group])
12891346

12901347
def add_host_to_groups(self, host, hostname):
1291-
1292-
# If we're grouping by regions, hosts are not added to region groups
1293-
# - the site groups are added as sub-groups of regions
1294-
# So, we need to make sure we're also grouping by sites if regions are enabled
1295-
1296-
if "region" in self.group_by:
1297-
# Make sure "site" or "sites" grouping also exists, depending on plurals options
1298-
site_group_by = self._pluralize_group_by("site")
1299-
if site_group_by not in self.group_by:
1300-
self.group_by.append(site_group_by)
1348+
site_group_by = self._pluralize_group_by("site")
13011349

13021350
for grouping in self.group_by:
13031351

1304-
# Don't handle regions here - that will happen in main()
1305-
if grouping == "region":
1352+
# Don't handle regions here since no hosts are ever added to region groups
1353+
# Sites and locations are also specially handled in the main()
1354+
if grouping in ["region", site_group_by, "location"]:
13061355
continue
13071356

13081357
if grouping not in self.group_extractors:
13091358
raise AnsibleError(
1310-
'group_by option "%s" is not valid. (Maybe check the plurals option? It can determine what group_by options are valid)'
1359+
'group_by option "%s" is not valid. Check group_by documentation or check the plurals option. It can determine what group_by options are valid.'
13111360
% grouping
13121361
)
13131362

@@ -1331,53 +1380,76 @@ def add_host_to_groups(self, host, hostname):
13311380
transformed_group_name = self.inventory.add_group(group=group_name)
13321381
self.inventory.add_host(group=transformed_group_name, host=hostname)
13331382

1334-
def _add_region_groups(self):
1383+
def _add_site_groups(self):
1384+
# Map site id to transformed group names
1385+
self.site_group_names = dict()
13351386

1336-
# Mapping of region id to group name
1337-
region_transformed_group_names = dict()
1338-
1339-
# Create groups for each region
1340-
for region_id in self.regions_lookup:
1341-
region_group_name = self.generate_group_name(
1342-
"region", self.regions_lookup[region_id]
1387+
for site_id, site_name in self.sites_lookup.items():
1388+
site_group_name = self.generate_group_name(
1389+
self._pluralize_group_by("site"), site_name
13431390
)
1344-
region_transformed_group_names[region_id] = self.inventory.add_group(
1345-
group=region_group_name
1391+
# Add the site group to get its transformed name
1392+
site_transformed_group_name = self.inventory.add_group(
1393+
group=site_group_name
13461394
)
1395+
self.site_group_names[site_id] = site_transformed_group_name
13471396

1348-
# Now that all region groups exist, add relationships between them
1349-
for region_id in self.regions_lookup:
1350-
region_group_name = region_transformed_group_names[region_id]
1351-
parent_region_id = self.regions_parent_lookup.get(region_id, None)
1352-
if (
1353-
parent_region_id is not None
1354-
and parent_region_id in region_transformed_group_names
1355-
):
1356-
parent_region_name = region_transformed_group_names[parent_region_id]
1357-
self.inventory.add_child(parent_region_name, region_group_name)
1397+
def _add_region_groups(self):
1398+
# Mapping of region id to group name
1399+
region_transformed_group_names = self._setup_nested_groups(
1400+
"region", self.regions_lookup, self.regions_parent_lookup
1401+
)
13581402

13591403
# Add site groups as children of region groups
13601404
for site_id in self.sites_lookup:
13611405
region_id = self.sites_region_lookup.get(site_id, None)
13621406
if region_id is None:
13631407
continue
13641408

1365-
region_transformed_group_name = region_transformed_group_names[region_id]
1366-
1367-
site_name = self.sites_lookup[site_id]
1368-
site_group_name = self.generate_group_name(
1369-
self._pluralize_group_by("site"), site_name
1370-
)
1371-
# Add the site group to get its transformed name
1372-
# Will already be created by add_host_to_groups - it's ok to call add_group again just to get its name
1373-
site_transformed_group_name = self.inventory.add_group(
1374-
group=site_group_name
1409+
self.inventory.add_child(
1410+
region_transformed_group_names[region_id],
1411+
self.site_group_names[site_id],
13751412
)
13761413

1414+
def _add_location_groups(self):
1415+
# Mapping of location id to group name
1416+
self.location_group_names = self._setup_nested_groups(
1417+
"location", self.locations_lookup, self.locations_parent_lookup
1418+
)
1419+
1420+
# Add location to site groups as children
1421+
for location_id, location_slug in self.locations_lookup.items():
1422+
if self.locations_parent_lookup.get(location_id, None):
1423+
# Only top level locations should be children of sites
1424+
continue
1425+
1426+
site_transformed_group_name = self.site_group_names[
1427+
self.locations_site_lookup[location_id]
1428+
]
1429+
13771430
self.inventory.add_child(
1378-
region_transformed_group_name, site_transformed_group_name
1431+
site_transformed_group_name, self.location_group_names[location_id]
13791432
)
13801433

1434+
def _setup_nested_groups(self, group, lookup, parent_lookup):
1435+
# Mapping of id to group name
1436+
transformed_group_names = dict()
1437+
1438+
# Create groups for each object
1439+
for obj_id in lookup:
1440+
group_name = self.generate_group_name(group, lookup[obj_id])
1441+
transformed_group_names[obj_id] = self.inventory.add_group(group=group_name)
1442+
1443+
# Now that all groups exist, add relationships between them
1444+
for obj_id in lookup:
1445+
group_name = transformed_group_names[obj_id]
1446+
parent_id = parent_lookup.get(obj_id, None)
1447+
if parent_id is not None and parent_id in transformed_group_names:
1448+
parent_name = transformed_group_names[parent_id]
1449+
self.inventory.add_child(parent_name, group_name)
1450+
1451+
return transformed_group_names
1452+
13811453
def _fill_host_variables(self, host, hostname):
13821454
extracted_primary_ip = self.extract_primary_ip(host=host)
13831455
if extracted_primary_ip:
@@ -1413,6 +1485,9 @@ def _fill_host_variables(self, host, hostname):
14131485
if attribute == "region":
14141486
attribute = "regions"
14151487

1488+
if attribute == "location":
1489+
attribute = "locations"
1490+
14161491
if attribute == "rack_group":
14171492
attribute = "rack_groups"
14181493

@@ -1456,6 +1531,27 @@ def main(self):
14561531
# - can skip any device/vm without any IPs
14571532
self.refresh_lookups(self.lookup_processes_secondary)
14581533

1534+
# If we're grouping by regions, hosts are not added to region groups
1535+
# If we're grouping by locations, hosts may be added to the site or location
1536+
# - the site groups are added as sub-groups of regions
1537+
# - the location groups are added as sub-groups of sites
1538+
# So, we need to make sure we're also grouping by sites if regions or locations are enabled
1539+
site_group_by = self._pluralize_group_by("site")
1540+
if (
1541+
site_group_by in self.group_by
1542+
or "location" in self.group_by
1543+
or "region" in self.group_by
1544+
):
1545+
self._add_site_groups()
1546+
1547+
# Create groups for locations. Will be a part of site groups.
1548+
if "location" in self.group_by and self.api_version >= version.parse("2.11"):
1549+
self._add_location_groups()
1550+
1551+
# Create groups for regions, containing the site groups
1552+
if "region" in self.group_by:
1553+
self._add_region_groups()
1554+
14591555
for host in chain(self.devices_list, self.vms_list):
14601556

14611557
virtual_chassis_master = self._get_host_virtual_chassis_master(host)
@@ -1488,9 +1584,18 @@ def main(self):
14881584
)
14891585
self.add_host_to_groups(host=host, hostname=hostname)
14901586

1491-
# Create groups for regions, containing the site groups
1492-
if "region" in self.group_by:
1493-
self._add_region_groups()
1587+
# Special processing for sites and locations as those groups were already created
1588+
if getattr(self, "location_group_names", None) and host.get("location"):
1589+
# Add host to location group when host is assigned to the location
1590+
self.inventory.add_host(
1591+
group=self.location_group_names[host["location"]["id"]],
1592+
host=hostname,
1593+
)
1594+
elif getattr(self, "site_group_names", None) and host.get("site"):
1595+
# Add host to site group when host is NOT assigned to a location
1596+
self.inventory.add_host(
1597+
group=self.site_group_names[host["site"]["id"]], host=hostname,
1598+
)
14941599

14951600
def parse(self, inventory, loader, path, cache=True):
14961601
super(InventoryModule, self).parse(inventory, loader, path)

tests/integration/netbox-deploy.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ def make_netbox_calls(endpoint, payload):
252252
},
253253
{"name": "Test Rack", "site": test_site.id, "group": created_rack_groups[0].id},
254254
]
255+
256+
## Use location instead of group for 2.11+
257+
if nb_version >= version.parse("2.11"):
258+
racks[1]["location"] = created_rack_groups[0].id
259+
del racks[1]["group"]
260+
255261
created_racks = make_netbox_calls(nb.dcim.racks, racks)
256262
test_rack = nb.dcim.racks.get(name="Test Rack") # racks don't have slugs
257263
test_rack_site2 = nb.dcim.racks.get(name="Test Rack Site 2")
@@ -293,6 +299,13 @@ def make_netbox_calls(endpoint, payload):
293299
"site": test_site.id,
294300
},
295301
]
302+
303+
## Add some locations for 2.11+
304+
if nb_version >= version.parse("2.11"):
305+
devices[0]["location"] = created_rack_groups[0].id
306+
devices[1]["location"] = created_rack_groups[0].id
307+
devices[3]["location"] = created_rack_groups[0].id
308+
296309
created_devices = make_netbox_calls(nb.dcim.devices, devices)
297310
### Device variables to be used later on
298311
test100 = nb.dcim.devices.get(name="test100")

0 commit comments

Comments
 (0)