diff --git a/scripts/helper_library/Dockerfile b/scripts/helper_library/Dockerfile new file mode 100644 index 0000000..a371c84 --- /dev/null +++ b/scripts/helper_library/Dockerfile @@ -0,0 +1,9 @@ +ARG NETBOX_BASE_IMAGE=docker.io/netboxcommunity/netbox:v3.2.9 + +FROM ${NETBOX_BASE_IMAGE} as netbox-test + +USER 0 + +RUN pip install coverage==7.2.7 + +USER 999 \ No newline at end of file diff --git a/scripts/helper_library/Makefile b/scripts/helper_library/Makefile new file mode 100644 index 0000000..d785966 --- /dev/null +++ b/scripts/helper_library/Makefile @@ -0,0 +1,31 @@ + +VERSION := $(shell git describe --tags --always --dirty="-dev") +PROJECT_ROOT := . + +BLACK_EXLCUDE := ".venv" + +.PHONY: clean pyvenv format ruff-fix lint coverage + +clean: + rm -rf $(PROJECT_ROOT)/.cover/ + +pyvenv: + python3 -m venv .venv + ./.venv/bin/pip install -r dev-testing-requirements.txt + +format: + ./.venv/bin/python -m black --exclude $(BLACK_EXLCUDE) ./ + +ruff-fix: + ./.venv/bin/python -m ruff check ./ --fix + +lint: pyvenv + ./.venv/bin/python -m ruff check ./ + ./.venv/bin/python -m black --exclude $(BLACK_EXLCUDE) ./ --check + +coverage: + mkdir -p $(PROJECT_ROOT)/.cover/ + chmod 0777 $(PROJECT_ROOT)/.cover/ + docker-compose -f docker-compose.test.yml up --exit-code-from netbox --abort-on-container-exit + docker cp netbox_test:/tmp/.cover ./$(PROJECT_ROOT)/ + open $(PROJECT_ROOT)/.cover/index.html diff --git a/scripts/helper_library/README.md b/scripts/helper_library/README.md new file mode 100644 index 0000000..4961c02 --- /dev/null +++ b/scripts/helper_library/README.md @@ -0,0 +1,71 @@ +# NetBox custom scripts helper library + +This library is intended to aid as a low-hanging entry point into NetBox scripting. + +It abstracts away some of the "Django things" from the user and also provides insight/ideas into how to work with the Django models and how to build unit tests for NetBox scripts. + +## Modules + +### Common Exceptions + +The [common/errors.py] library contain some two Exceptions used by this library. + +The `InvalidInput` error is raised when something we got from output doesn't make sense or isn't found and a `NetBoxDataError` is raised when something inside our data looks fishy. + +Both errors are handled by the `CommonScript*` wrappers and will be translated into an `AbortScript` error to abort the script with a nice error message. + +### Base scripts + +The [common/basescript.py] library contain some wrapper classes around the NetBox `Script` class. + +`CommonScript` handles permission handling and acts according to the presence (or absence) of the following class-level variables: + - require_user_and_group (bool): If True, validate user and group list, by default one is enough. + - allowed_users (list): List of usernames allowed to execute this scripts + - allowed_groups (list): List of groups (of users) allowed to execute this scripts +`CommonScript` should never be used directly. + +All scripts inheriting from `CommonScript` are expected to implement + + run_method(data, commit) + +which will be called when executed. + +Both `CommonUIScript` and `CommonAPIScript` inherit from `CommonScript`, so they support above permission handling. + +`CommonUIScript` does not add more fluff on top, "just" the permission handling. + +The `CommonAPIScript` adds a parameter validation layer on top. API scripts are expected to have one `request` parameter which contains the JSON encoded input parameters for the script. The `get_request_data()` methods tried to retrieve these parameters from the given `data` dict and unmarshal the JSON data. If the `request` parameter is missing or unmarshalling the JSON data fails, it will raise an `InvalidInput` Exception. + +The params dict is expected to be a (flat) dictionary holding the parameters of the script. Once it has been retrieved and unmarshalled validation should happen. +The + + validate_parameters(self, params: dict, key_to_validator_map: dict) -> None: + +method provides low-hanging access to basic validation functions. The `key_to_validator_map` holds a map from the parameter key to a validation function as defined in the `VALIDATOR_MAP` within the [common/validators.py] module. A value of `None`` can be used to indicate that only the existence of a parameter should be checked if no fitting validator exists (yet). The validators module also provides additional validators to check more complex things (e.g. is "IP x within subnet Y?"). + +### Utils + +The [common/utils.py] module provides a lot of wrapper functions to work with Devices, Interfaces, Front/Rear Ports (or any kind of `port`), Circuits and Terminations, Prefixes, IPs, Tags, etc. + +I also provides helper functions to find Prefixes with certain constraints, carve the next available sub-prefix(es) from it, and get the IPs from these. + +## Static analysis + +This code is linted and formated according to `ruff` and `black`, both are configured in `pyproject.toml`. + +The following Make targets exist to make your life easier: + * `make lint` will run both checks and could (read: should) be used in a CI environment :) + * `make format` will let `black` format the code as it sees fit + * `make ruff-fix` will run `ruff --fix` and can aid with fixing some of its complains + +## Testing scripts + +Besides linting/formating enforcements, this repo contains unit tests for nearly all parts of this library. + +For this `coverage` is run via `docker-compose`, as Django requires its own special testing environment which also requires a database instance. +Besides the NetBox container this also requires a PostgreSQL DB to be present, which will be set to NetBox baseline defaults by the Django test framework. +To simplify your unit-testing life, there is a `templates` fixture available in the `fixtures/` directory, see [fixtures/README.md] for details on what it contains, and [README.fixtures.md] on how to update it. + +The end-to-end unit tests live in `tests/`. + +To run them manually run `make coverage`. \ No newline at end of file diff --git a/scripts/helper_library/common/basescript.py b/scripts/helper_library/common/basescript.py new file mode 100644 index 0000000..10d6d26 --- /dev/null +++ b/scripts/helper_library/common/basescript.py @@ -0,0 +1,299 @@ +#!/usr/bin/python3 + +"""Common NetBox Script wrapper classes. + +This library provides the CommonUIScript and CommonAPIScript wrapper classes. + +They provide ready to use validations for permissions (if required), as well as for +validating input parameters. See the validators module for details on these. +""" + +import json + +from extras.scripts import Script + +# Compatibility glue for NetBox version < 3.4.4 +try: + from utilities.exceptions import AbortScript +except ImportError: + + class AbortScript(Exception): + """Raised to cleanly abort a script.""" + + pass + + +import scripts.common.validators +from django.contrib.auth.models import User +from scripts.common.utils import InvalidInput, NetBoxDataError + +################################################################################ +# Base classes for NetBox Scripts # +################################################################################ + + +class CommonScript(Script): # pragma: no cover + """Common wrapper class for NetBox scripts. + + No script should directly inherit from this class, but rather from + - CommonUIScript for interactive scripts, or + - CommonAPIScript for scripts to be called via the API + + This class handles permission handling and acts according to the presence (or absence) + of the following class-level variables: + - require_user_and_group (bool): If True, validate user and group list, by default one is enough. + - allowed_users (list): List of usernames allowed to execute this scripts + - allowed_groups (list): List of groups (of users) allowed to execute this scripts + """ + + def _get_require_user_and_group(self) -> bool: + if not hasattr(self, "require_user_and_group"): + return False + + require_user_and_group = getattr(self, "require_user_and_group") + if not isinstance(require_user_and_group, bool): + raise AbortScript( + "Script class attribute 'require_user_and_group' must be boolean!" + ) + if require_user_and_group: + for attr in ["allowed_users", "allowed_groups"]: + if not hasattr(self, attr): + raise AbortScript( + f"Script has 'require_user_and_group' set to True, but is missing {attr}" + ) + + return require_user_and_group + + def validate_permissions(self) -> None: + """Validate permissions of user/group running this script (if set).""" + # request is None in unit tests + if self.request is None: + return + + username = self.request.user.username + + # Just bail if no checks are configured + if not hasattr(self, "allowed_users") and not hasattr(self, "allowed_groups"): + self.log_info("Everyone is allowed to execute this script.") + return + + # By default we require user OR group check to succeed, shall we check both? + require_user_and_group = self._get_require_user_and_group() + + if hasattr(self, "allowed_users"): + users = getattr(self, "allowed_users") + if not isinstance(users, list): + raise AbortScript( + "Script class attribute 'allowed_users' must be a list!" + ) + + if username not in users: + msg = f"User '{self.request.user.username}' is not allowed to execute this script! " + self.log_failure(msg) + raise AbortScript(msg) + + if not require_user_and_group: + return + + # Either there was no allowed_users attribute and it was satisfied or it's not required, + # so check group permissions, if configured. + if hasattr(self, "allowed_groups"): + allowed_groups = getattr(self, "allowed_groups") + if not isinstance(allowed_groups, list): + raise Exception( + "Script class attribute 'allowed_groups' must be a list!" + ) + + user = User.objects.get(username=username) + + user_groups = [str(g) for g in user.groups.all()] + if len(set(allowed_groups) & set(user_groups)) == 0: + msg = f"User '{self.request.user.username}' is not allowed to execute this script (by group check)." + self.log_failure(msg) + raise AbortScript(msg) + + # If we're still here and didn't bail out, the user is allowed. + self.log_info( + f"User '{self.request.user.username}' is allowed to execute this script." + ) + + def run(self, data: dict, commit: bool = False) -> any: + """Run method called by NetBox. + + This method needs to be implemented by script inheriting from this class. + """ + raise NotImplementedError( + "This class should never be used directly, inherit from CommonUIScript instead!" + ) + + +class CommonUIScript(CommonScript): # pragma: no cover + """This class acts as a base class for all user facing custom scripts used via the NetBox WebUI. + + Important note: Scripts that inherit fromt his class need to: + - Implement the run_method() method + """ + + def run(self, data: dict, commit: bool = False) -> any: + """Run method called by NetBox.""" + self.validate_permissions() + + try: + return self.run_method(data, commit) + except InvalidInput as e: + raise AbortScript(f"Invalid input: {str(e)}") + except NetBoxDataError as e: + raise AbortScript(f"Potential data inconsistency error: {str(e)}") + + +class CommonAPIScript(CommonScript): + """This class acts as a base class for custom scripts which should be solely run via API calls. + + Important note: Scripts that inherit fromt his class need to: + - Implement the run_method() method + + Any Script which inherits from this class will always return a dictionary of the following format + regard less of the fate of the script and how spectacularly it blew up or ran sucessfully: + { + "success": bool, + "logs": list[dict], + "ret": Optional[any], + "errmsg": Optional[str], + } + + Example for a successful run w/o return value + { + "success": True, + "logs": [ + {"sev": "info", "msg": "Everyone is allowed to execute this script."}, + {"sev": "info", "msg": "IP 192.0.2.2/29 already configured on interface et18 on cr01.pad01"}, + {"sev": "info", "msg": "Gateway IP 192.0.2.1 already set."} + ], + "ret": null, + "errmsg": null + } + + Example for a successful run w/ return value (single value here, could be a dict, too) + { + "success": True, + "logs": [ + {"sev": "info", "msg": "Everyone is allowed to execute this script."}, + {"sev": "info", "msg": "Found Device edge01.pad01"}, + {"sev": "success", "msg": "Device cr01.pad01 has capacity left for 62 (of 62) + additional connections."}, + ... + ], + "ret": "192.0.2.42", + "errmsg": null + } + + Example of a failure: + { + "success": False, + "logs": [ + {"sev": "info", "msg": "Everyone is allowed to execute this script."} + ], + "ret": null, + "errmsg": "Given value "192.0.2.267/292" for parameter "ip" is not a valid IP prefix!" + } + """ + + def _get_logs(self) -> list[str]: + logs = [] + + for entry in self.log: + logs.append( + { + "sev": entry[0], + "msg": entry[1], + } + ) + + return logs + + def _ret_dict_json(self, success: bool, ret: any = None, errmsg: str = None) -> str: + return json.dumps( + { + "success": success, + "logs": self._get_logs(), + "ret": ret, + "errmsg": errmsg, + } + ) + + def _ret_error(self, errmsg: str) -> dict: + return self._ret_dict_json(False, errmsg=errmsg) + + def _ret_success(self, ret: any) -> dict: + return self._ret_dict_json(True, ret) + + def get_request_data(self, data: dict) -> dict: + """Retrieve script's request parameters and parse JSON. + + API scripts are expected to have one 'request' parameter which contains + the JSON encoded input parameters for the script. This methods tried to + retrieve these parameters from the given 'data' dict and unmarshal the JSON data. + If the 'request' parameter is missing or unmarshalling the JSON data fails, + it will raise an InvalidInput Exception. + """ + try: + req_data = data["request"] + if isinstance(req_data, dict): + return req_data + + return json.loads(req_data) + except KeyError: + raise InvalidInput("Missing 'request' parameter!") + except (TypeError, json.JSONDecodeError) as e: + raise InvalidInput("Failed to unmarshal request JSON: %s" % e) + + def validate_parameters(self, params: dict, key_to_validator_map: dict) -> None: + """Validate parameters passed to the script. + + The params dict is expected to be a (flat) dictionary holding the parameters of the script, + likely the output of get_request_data(). + + The key_to_validator_map holds a map from the parameter key to a validation function as defined + in the VALIDATOR_MAP within the common/validators module. A value of None can be used to indicate + that only the existence of a parameter should be checked if no fitting validator exists yet. + """ + if not isinstance(params, dict): + raise InvalidInput( + f"Given 'params' is not a dictonary, but rather {type(params)}." + ) + + for key in sorted(key_to_validator_map.keys()): + if key not in params: + raise InvalidInput(f"Expected parameter '{key}' missing!") + value = params[key] + + # Validator may be None, so we only validate parameter's existance + validator = key_to_validator_map[key] + if validator is None: + continue + + validator_func = scripts.common.validators.VALIDATOR_MAP.get(validator) + if validator_func is None: + raise InvalidInput(f"No validator function found for {validator}!") + + validator_func(key, value) + + def run(self, data: dict, commit: bool = False) -> any: + """Run method called by NetBox.""" + self.validate_permissions() + + try: + return self._ret_success(self.run_method(data, commit)) + except InvalidInput as e: + errmsg = str(e) + self.output = self._ret_error(errmsg) + raise AbortScript(f"Invalid input: {errmsg}") + except NetBoxDataError as e: + errmsg = str(e) + self.output = self._ret_error(errmsg) + raise AbortScript(f"Potential data inconsistency error: {errmsg}") + except Exception as e: + errmsg = str(e) + self.output = self._ret_error(f"An unexpected error occured: {errmsg}") + # Raise the Exception as-is to show the stack trace + raise e diff --git a/scripts/helper_library/common/constants.py b/scripts/helper_library/common/constants.py new file mode 100644 index 0000000..76f729b --- /dev/null +++ b/scripts/helper_library/common/constants.py @@ -0,0 +1,35 @@ +#!/usr/bin/python3 + +"""Common constants for items in NetBox.""" + +CIRCUIT_TYPE_SLUG_DARK_FIBER = "dark-fiber" + +DEVICE_ROLE_SLUG_CS = "console-server" +DEVICE_ROLE_SLUG_EDGE_ROUTER = "edge-router" + +PLATFORM_SLUG_EOS = "eos" +PLATFORM_SLUG_JUNOS = "junos" +PLATFORM_SLUG_JUNOS_EVO = "junos-evo" + +PREFIX_ROLE_SLUG_LOOPBACK_IPS = "loopback-ips" +PREFIX_ROLE_SLUG_SITE_LOCAL = "site-local" +PREFIX_ROLE_SLUG_TRANSFER_NETWORK = "transfer-network" + +# Tags +TAG_NAME_NET_DHCP = "NET:DHCP" +TAG_NAME_NET_OOBM = "NET:OOBM" + +# LAG basenames + +PLATFORM_TO_LAG_BASENAME_MAP = { + "eos": ("Port-Channel", 1), + "junos": ("ae", 0), + "junos-evo": ("ae", 0), + "nxos": ("Port-Channel", 1), +} + +MANUFACTURER_TO_LAG_BASENAME_MAP = { + "arista": ("Port-Channel", 1), + "cisco": ("Port-Channel", 1), + "juniper": ("ae", 0), +} diff --git a/scripts/helper_library/common/errors.py b/scripts/helper_library/common/errors.py new file mode 100644 index 0000000..9ae9394 --- /dev/null +++ b/scripts/helper_library/common/errors.py @@ -0,0 +1,19 @@ +#!/usr/bin/python3 + +"""Custom Exceptions used in the NetBox scripts .""" + +################################################################################ +# Exceptions # +################################################################################ + + +class InvalidInput(Exception): + """Raised if we detected invalid input data.""" + + pass + + +class NetBoxDataError(Exception): + """Raised if something we expected to be there, doesn't exist.""" + + pass diff --git a/scripts/helper_library/common/utils.py b/scripts/helper_library/common/utils.py new file mode 100644 index 0000000..95f32a0 --- /dev/null +++ b/scripts/helper_library/common/utils.py @@ -0,0 +1,1570 @@ +#!/usr/bin/python3 + +"""NetBox scripts utils library.""" + +import re +from typing import Literal, Optional, Union + +import netaddr.ip +from circuits.models import Circuit, CircuitTermination +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import ( + Cable, + ConsolePort, + ConsoleServerPort, + Device, + FrontPort, + Platform, + RearPort, + Site, +) +from dcim.models.device_components import Interface +from extras.models import Tag +from extras.scripts import Script +from ipam.choices import PrefixStatusChoices +from ipam.models import VRF, IPAddress, Prefix, Role +from netaddr import IPNetwork, IPSet +from netbox.settings import VERSION +from scripts.common.constants import ( + MANUFACTURER_TO_LAG_BASENAME_MAP, + PLATFORM_TO_LAG_BASENAME_MAP, +) +from scripts.common.errors import InvalidInput, NetBoxDataError +from utilities.choices import ColorChoices + +SINGLE_CABLE_ENDPOINT = [int(n) for n in VERSION.split("-")[0].split(".")] < [3, 3, 0] + +NET_MaxVRFCount_Tag_re = re.compile(r"^NET:MaxVRFCount=(\d+)$") +INTERFACE_TYPE_COMPATIBILITY = { + # : [list of port speeds which can be connected] + "25gbase-x-sfp28": [ + "10gbase-x-sfpp", + "10gbase-x-xfp", + "10gbase-x-xenpak", + "10gbase-x-x2", + ], +} +DEVICE__NAME_RE = re.compile(r"^([a-z]+\d+)\.([a-z]+\d+)$") + +AF_IPv4 = 4 +AF_IPv6 = 6 + +################################################################################ +# Generic wrappers # +################################################################################ + + +def log_maybe(script: Script, level: str, msg: str) -> None: + """Log the given msg with the given level if the 'script' is not None.""" + if script is None: + return + + func_name = f"log_{level}" + if not hasattr(script, func_name): + raise Exception("Invalid log level!") + + func = getattr(script, func_name) + func(msg) + + +def _get_port_type( + port: Union[ConsolePort, ConsoleServerPort, Interface, FrontPort, RearPort] +) -> str: + if isinstance(port, ConsolePort): + return "Console Port" + + if isinstance(port, ConsoleServerPort): + return "Console Server Port" + + if isinstance(port, Interface): + return "Interface" + + if isinstance(port, FrontPort): + return "Front Port" + + if isinstance(port, RearPort): + return "Rear Port" + + return "unknown" + + +################################################################################ +# Circuit related helper functions # +################################################################################ + + +def terminate_circuit_at_site( + circuit: Circuit, + site: Site, + a_end: bool, + script: Optional[Script] = None, +) -> CircuitTermination: + """Terminate the given Circuit at the given Site. + + Parameters + ---------- + circuit : Circuit + The Circuit to terminate at the given Site. + site : Site + Site to create the CircuitTerminate at. + a_end: bool + True is A-End should be terminate at the given Site, False for Z-End. + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + CircuitTermination + Return a circuit.CircuitTermination object. + """ + if a_end: + termination = circuit.termination_a + else: + termination = circuit.termination_z + + # Does circuit already have a termination? + if termination is not None: + if termination.site == site: + log_maybe( + script, + "info", + f"Circuit {str(circuit)} already terminating at site {site.name}", + ) + return termination + + raise InvalidInput( + f"Circuit {str(circuit)} already terminated at site {str(termination.site)}," + f"but should land at site {site.name}" + ) + + term_side = "A" if a_end else "Z" + ct = CircuitTermination( + circuit=circuit, + term_side=term_side, + site=site + # xconnect_id + # pp_info + ) + ct.save() + + log_maybe( + script, + "success", + f"Terminated {term_side} end of circuit {str(circuit)} at site {site.name}", + ) + + return ct + + +def connect_circuit_termination_to_port( + ct: CircuitTermination, + port: Union[Interface, FrontPort, RearPort], + planned: bool = False, + script: Optional[Script] = None, +) -> Cable: + """Connect the given CircuitTermination to the given port. + + Parameters + ---------- + ct : CircuitTermination + The CircuitTermination to connect to a port. + port : Union[Interface, FrontPort, RearPort] + Port to connect CircuitTerminat to. + planned: bool, optional + True if the cable should have status Planned, False for Connected (default False) + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Cable + Return a dcim.models.Cable object. + """ + port_type = _get_port_type(port) + + if ct.cable is not None: + msg = f"{ct} of circuit {ct.circuit} already connected!" + log_maybe(script, "failure", msg) + raise InvalidInput(msg) + + if port._link_peer is not None: + msg = ( + f"Error while connecting {ct.circuit} to {port_type} {port.name}" + f"on device {port.device.name}: {port_type} already connected!" + ) + log_maybe(script, "failure", msg) + raise InvalidInput(msg) + + status = LinkStatusChoices.STATUS_CONNECTED + if planned: + status = LinkStatusChoices.STATUS_PLANNED + if SINGLE_CABLE_ENDPOINT: + c = Cable(status=status, termination_a=ct, termination_b=port) + else: + c = Cable(status=status, a_terminations=[ct], b_terminations=[port]) + c.save() + + log_maybe( + script, + "success", + f"Connected circuit {ct.circuit} {ct} to {port_type} {port} on {port.device.name}", + ) + + return c + + +################################################################################ +# Cabling related helper functions # +################################################################################ + + +def connect_ports( + port_a: Union[Interface, FrontPort, RearPort], + port_b: Union[Interface, FrontPort, RearPort], + planned: bool = False, + script: Optional[Script] = None, +) -> Cable: + """Connect the given ports via a direct Cable. + + If the two ports are already connected to each other, the function will create a log entry + (if script is given) and return. If any of the two ports is connected to something else, + this will raise an InvalidInput Exception. + + Parameters + ---------- + port_a : Union[Interface, FrontPort, RearPort] + A-End of cable. + port_b : Union[Interface, FrontPort, RearPort] + Z-End of cable. + planned: bool, optional + True if the cable should have status Planned, False for Connected (default False) + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Cable + Return a dcim.models.Cable object. + """ + port_desc_a = get_port_string(port_a) + port_desc_b = get_port_string(port_b) + + if port_a._link_peer is not None or port_a._link_peer is not None: + if port_a._link_peer == port_b: + log_maybe( + script, + "info", + f"{port_desc_a} and {port_desc_b} already connected", + ) + return port_a.cable + + msg = f"{port_desc_a} already connected to something else!" + log_maybe(script, "failure", msg) + raise InvalidInput(msg) + + if port_b._link_peer is not None or port_b._link_peer is not None: + msg = f"{port_desc_b} already connected to something else!" + log_maybe(script, "failure", msg) + raise InvalidInput(msg) + + status = LinkStatusChoices.STATUS_CONNECTED + if planned: + status = LinkStatusChoices.STATUS_PLANNED + + if SINGLE_CABLE_ENDPOINT: + c = Cable(status=status, termination_a=port_a, termination_b=port_b) + else: + c = Cable(status=status, a_terminations=[port_a], b_terminations=[port_b]) + c.save() + + log_maybe( + script, + "success", + f"Connected {port_desc_a} and {port_desc_b} with a cable", + ) + + return c + + +def remove_existing_cable_if_exists( + iface: Interface, script: Optional[Script] = None +) -> Interface: + """If there is an existing cable terminating at iface remove the cable and re-fetch the Interface and return it. + + If there is not cable attached, just return the Interface. + + Parameters + ---------- + iface : Interface + The Interface to remove a connected cable from, if any + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Interface + The unchanged Interface if not cable is connected, or the newly fetched Interface from the DB + """ + if iface._link_peer is None: + log_maybe( + script, + "info", + f"No cable connected to interface {iface.name} on {iface.device}", + ) + return iface + + cable = iface.cable + remote = get_other_cable_end_string(cable, iface) + log_maybe( + script, + "warning", + f"Removed cable on interface {iface.name} on {iface.device} connected to {remote}", + ) + + cable.delete() + + return Interface.objects.get(pk=iface.pk) + + +def get_other_cable_end_string( + cable: Cable, port: Union[Interface, FrontPort, RearPort] +) -> str: + """Return the remote end of the given cable / interface as a string (for logging). + + Parameters + ---------- + cable : Cable + The cable at hand. + port : Union[Interface, FrontPort, RearPort] + The local port. + + Returns + ------- + str + Port description of the remote end. + """ + if SINGLE_CABLE_ENDPOINT: + if port == cable.termination_a: + return get_port_string(cable.termination_b) + + elif port == cable.termination_b: + return get_port_string(cable.termination_a) + else: + if port == cable.termination_a[0]: + return get_port_string(cable.b_terminations[0]) + + elif port == cable.termination_b[0]: + return get_port_string(cable.a_terminations[0]) + + port_desc = get_port_string(port) + raise NetBoxDataError(f"Given cable not connected to {port_desc}") + + +################################################################################ +# Device related helper functions # +################################################################################ + + +def get_device(device_name: str) -> Optional[Device]: + """Get a device from the DB. + + This will return None if the device does not exist. + + Parameters + ---------- + device_name : str + The name of the Device to look for. + + Returns + ------- + Device or None + Return a dcim.models.Device object. + """ + try: + return Device.objects.get(name=device_name) + except Device.DoesNotExist: + return None + + +def get_device_platform_slug(device: Device) -> Optional[str]: + """Get a device's platform (slug). + + Parameters + ---------- + device: dcim.models.Device + The device object to get the platform slug from. + + Returns + ------- + str, optional + Return the platform slug string, or None if no platform is set. + """ + if device.platform is None: + return None + + return device.platform.slug + + +def set_device_platform( + device: Device, platform_slug: str, script: Optional[Script] = None +) -> None: + """Set the given Platform to the given device. + + Parameters + ---------- + device: dcim.models.Device + The device object to set the platform on. + platform_slug: str + The slug of the Platform to set on the device. + script: Script, optional + Script object this function is called from, used for logigng. + """ + try: + plat = Platform.objects.get(slug=platform_slug) + except Platform.DoesNotExist: + raise InvalidInput(f"Platform (slug) {platform_slug} does not exist!") + + if device.platform == plat: + log_maybe( + script, + "info", + f"Device {device.name} already has platform (slug) {platform_slug} set.", + ) + return + + device.platform = plat + device.save() + + log_maybe( + script, + "success", + f"Set platform (slug) {platform_slug} to device {device.name}", + ) + + +def get_device_lag_base_cfg(device: Device) -> tuple[str, int]: + """Get the LAG basename of the given device, e.g. 'ae' or 'Port-Channel'. + + This will raise an NetBoxDataError if neither for the device's platform (if any) + nor for the device's manufacturer a LAG basename config is defined. + + Parameters + ---------- + device: dcim.models.Device + The device to get the LAG basename for. + + Returns + ------- + str: + The basename for LAG interfaces. + int: + The minimum LAG number. + """ + platform_slug = get_device_platform_slug(device) + if platform_slug: + lag_basename_cfg = PLATFORM_TO_LAG_BASENAME_MAP.get(platform_slug) + if lag_basename_cfg is not None: + return lag_basename_cfg + + manufacturer_slug = device.device_type.manufacturer.slug + lag_basename_cfg = MANUFACTURER_TO_LAG_BASENAME_MAP.get(manufacturer_slug) + if lag_basename_cfg is None: + raise NetBoxDataError( + f"No LAG base config found for platform (slug) {platform_slug} " + f"nor manufacturer (slug) {manufacturer_slug}!" + ) + + return lag_basename_cfg + + +def get_device_max_VRF_count(device: Device) -> int: + """Get the maximum number of VRFs possible on the given device. + + Some devices / device types, we can only have a fixed number of VRFs. + This is indicated by tagging the device type with "NET:MaxVRFCount=". + + If no maximum VRF information is stored for the device type of the given device, + this will raise a NetBoxDataError Exception. + + Parameters + ---------- + device : Device + A dcim.models.Device object. + + Returns + ------- + int + The number of VRFs supported on the device. + """ + device_type = device.device_type + + max_VRFs = 0 + for tag in device_type.tags.all(): + match = NET_MaxVRFCount_Tag_re.match(tag.name) + if not match: + continue + + if max_VRFs != 0: + raise NetBoxDataError( + f"Found multiple values for NET:MaxVRFCount tag: {max_VRFs} vs. {match.group(1)}!" + ) + max_VRFs = int(match.group(1)) + + if max_VRFs == 0: + raise NetBoxDataError( + f"Can't figure out how many VRFs devices of type {device_type} support - dying of shame." + ) + + return max_VRFs + + +################################################################################ +# Interface related helper functions # +################################################################################ + + +def get_port_string( + port: Union[ConsolePort, ConsoleServerPort, Interface, FrontPort, RearPort] +) -> str: + """Get the description of the given port. + + Parameters + ---------- + port : Union[ConsolePort, ConsoleServerPort, Interface, FrontPort, RearPort] + Any kind of port object. + + Returns + ------- + str + The printable port string + """ + port_type = _get_port_type(port) + return f"{port_type} {port} on {port.device.name}" + + +def get_remote_interface(iface: Interface) -> Optional[Interface]: + """Get the remote interface the given interface is connected to (if any). + + If the given iface is a LAG, it will return the remote LAG interface (if any), + None, if no member interfaces exist or all of them are unconnected. It will + raise a NetBoxDataError, if LAG members are connected to different remote + devices, are part of different LAGs on the remote device, or the remote + interfaces aren't part of a LAG at all. + + Parameters + ---------- + iface : Interface + The Interface to trace from. + + Returns + ------- + Interface or None + Return a dcim.models.device_components.Interface object, or None. + """ + if iface.is_lag: + return get_remote_interface_LAG(iface) + + return get_remote_interface_native(iface) + + +def get_remote_interface_native(iface: Interface) -> Optional[Interface]: + """Get the remote interface the given phyiscal Interface if connected to. + + Parameters + ---------- + iface : Interface + The Interface to trace from. + + Returns + ------- + Interface + Return a dcim.models.device_components.Interface object, or None. + """ + if SINGLE_CABLE_ENDPOINT: + return iface.connected_endpoint + + if iface.connected_endpoints: + return iface.connected_endpoints[0] + + return None + + +def get_remote_interface_LAG(iface: Interface) -> Optional[Interface]: # noqa: C901 + """Get the remote interface the given LAG Interface if connected to. + + Parameters + ---------- + iface : Interface + The Interface to trace from. + + Returns + ------- + Interface or None + Return a dcim.models.device_components.Interface object, or None. + """ + device_name = iface.device.name + lag_members = get_LAG_members(iface) + if len(lag_members) == 0: + return None + + peer_ifaces = [] + for iface in lag_members: + peer_iface = None + if SINGLE_CABLE_ENDPOINT: + peer_iface = iface.connected_endpoint + else: + if iface.connected_endpoints: + peer_iface = iface.connected_endpoints[0] + + if peer_iface is None or type(iface) != Interface: + continue + + peer_ifaces.append(peer_iface) + + if len(peer_ifaces) == 0: + return None + + lag = peer_ifaces[0].lag + remote_device = peer_ifaces[0].device + for peer_iface in peer_ifaces: + if peer_iface.device != remote_device: + raise NetBoxDataError( + f"Members of LAG {iface} on device {device_name} are connected to different remote devices:" + f"{remote_device} vs. {peer_iface.remote_device}" + ) + + if peer_iface.lag is not None and peer_iface.lag != lag: + raise NetBoxDataError( + f"At least one member of LAG {iface} on device {device_name} is part of a different LAG" + f"on the remote end ({lag} vs. {iface.lag})" + ) + + if lag is None: + raise NetBoxDataError( + f"None of the members of LAG {iface} on device {device_name} connect an interface which are part of LAG" + ) + + return lag + + +def get_interface(device: Device, if_name: str) -> Optional[Interface]: + """Look up Interface with name 'if_name' on the given 'device'. + + Returns 'None' if Interface doesn't exist. + + Parameters + ---------- + device : Device + The Device the interface exist on. + if_name: str + The name of the Interface. + + Returns + ------- + Interface or None + Return a dcim.models.device_components.Interface object, or None. + """ + try: + return Interface.objects.get(device=device, name=if_name) + except Interface.DoesNotExist: + return None + + +def interface_types_compatible( + iface: Interface, type_to_connect: InterfaceTypeChoices +) -> bool: + """Check if the given Interface is compatible with the given speed. + + Parameters + ---------- + iface : Interface + The Interface object to check compatibility against. + type_to_connect: InterfaceTypeChoices + An interface type to connect to the given iface. + + Returns + ------- + bool + True if the interfaces are compatible, False if not. + """ + if iface.type == type_to_connect: + return True + + if iface.type in INTERFACE_TYPE_COMPATIBILITY: + return type_to_connect in INTERFACE_TYPE_COMPATIBILITY[iface.type] + + return False + + +def create_interface( + device: Device, + ifname: str, + port_type: InterfaceTypeChoices, + desc: str = "", + script: Script = None, +) -> Interface: + """Create a new interface. + + Parameters + ---------- + device : Device + The Device to create an Interface on. + ifname: str + Name of the Interface to create. + port_type: InterfaceTypeChoices + Type of the Interface to create. + desc: str, optional + Description to set in the interface. + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Interface + Return a dcim.models.device_components.Interface object. + """ + iface = Interface(device=device, name=ifname, type=port_type, description=desc) + + iface.save() + log_maybe(script, "success", f"Created interface {ifname} on device {device.name}") + + return iface + + +def get_or_create_interface( + device: Device, + ifname: str, + port_type: InterfaceTypeChoices, + desc: str = "", + script: Script = None, +) -> tuple[Interface, bool]: + """Look up Interface with name 'if_name' on the given 'device' or create if it isn't present. + + It will raise an NetBoxDataError if the Interfaces exists but has a different port_type. + + Parameters + ---------- + device : Device + The Device to create an Interface on. + ifname: str + Name of the Interface to create. + port_type: InterfaceTypeChoices + Type of the Interface to create. + desc: str, optional + Description to set in the interface. + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Interface + Return a dcim.models.device_components.Interface object. + bool + True if the Interface was created, False if it already existed. + """ + res = Interface.objects.filter(device=device, name=ifname) + + if len(res) > 0: + iface = res[0] + if iface.type != port_type: + raise NetBoxDataError( + f"{get_port_string(iface)} exist, but has wrong type, expected {port_type} found {iface.type}" + ) + + log_maybe(script, "info", f"Found interface {ifname} on device {device.name}") + return iface, False + + iface = create_interface(device, ifname, port_type, desc, script) + + return iface, True + + +def get_or_create_LAG_interface_with_members( + device: Device, + ifname: str, + members: list[Interface], + desc: Optional[str] = "", + script: Optional[Script] = None, +) -> tuple[Interface, bool]: + """Look up LAG Interface with name 'if_name' on the given 'device' or create it if it isn't present. + + If the LAG was created, set the given member interfaces to be part of this LAG, if it already existed + validate that all given members are part of this LAG and raise an NetBoxDataError if they aren't. + + Parameters + ---------- + device : Device + The Device to create an Interface on. + ifname: str + Name of the Interface to create. + members: list[Interface] + A list of Interfaces in the LAG + desc: str, optional + Description to set in the interface. + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Interface + Return a dcim.models.device_components.Interface object. + bool + True if the Interface was created, False if it already existed. + """ + lag, created = get_or_create_interface( + device, ifname, InterfaceTypeChoices.TYPE_LAG, desc, script + ) + + for iface in members: + # iface is not part of a LAG + if iface.lag is None: + iface.lag = lag + iface.save() + + # iface already is part of a LAG + if iface.lag != lag: + raise NetBoxDataError( + f"{get_port_string(iface)} should be placed in LAG {lag}, but is member of LAG {iface.lag}!" + ) + + return lag, created + + +def create_next_available_LAG_interface( + device: Device, + desc: Optional[str] = "", + start_at: Optional[int] = None, + override_basename: Optional[str] = None, + script: Optional[Script] = None, +) -> Interface: + """Create the next available LAG interface on the given device, using the given basename. + + Parameters + ---------- + device : Device + The Device to create an Interface on. + desc: str, optional + Description to set in the interface. + start_at: int, optional + Non-negative integer value denoting at which interface number to start. + Usually derived from device's platform / manufacturer. + override_basename: str, optional + Override basename of LAG devices on this device, e.g. "ae", "bond", or "Port-Channel". + Usually derived from device's platform / manufacturer. + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Interface + Return a dcim.models.device_components.Interface object. + """ + if override_basename: + lag_basename = override_basename + start_at_default = 1 + else: + # This will raise a NetBoxDataError if we can't find a config + lag_basename, start_at_default = get_device_lag_base_cfg(device) + + existing_lags = Interface.objects.filter( + device=device, type=InterfaceTypeChoices.TYPE_LAG + ) + + next_lag_number = start_at_default + if start_at is not None: + try: + next_lag_number = int(start_at) + if start_at < 0: + raise InvalidInput( + f"Invalid value for 'start_at' paramter, needs to be > 0: {start_at}" + ) + except (TypeError, ValueError): + raise InvalidInput( + f"Invalid value for 'start_at' paramter, not an integer: {start_at}" + ) + + lag_re = re.compile(rf"^{lag_basename}(\d+)$") + lag_numbers = [] + for lag in existing_lags: + match = lag_re.search(lag.name) + if match is None: # pragma: nocover + log_maybe( + script, + "warning", + f"Found LAG {lag.name}, which didn't match basename {lag_basename}", + ) + continue + + lag_numbers.append(int(match.group(1))) + + # Make 100% sure we're traversing the LAG indexes in ascending order + for lag_number in sorted(lag_numbers): + if lag_number > next_lag_number: + break + + if lag_number == next_lag_number: + next_lag_number += 1 + + next_lag_name = f"{lag_basename}{next_lag_number}" + + lag, _ = get_or_create_interface( + device, next_lag_name, InterfaceTypeChoices.TYPE_LAG, desc, script + ) + return lag + + +def get_child_interfaces(iface: Interface) -> list[Interface]: + """Look up all Interfaces of 'device' which have 'iface: Interfaces their parent.""" + return list(Interface.objects.filter(parent=iface)) + + +def get_LAG_members(lag: Interface) -> list[Interface]: + """Look up all Interfaces which have 'lag' Interfaces as their parent.""" + return list(Interface.objects.filter(lag=lag)) + + +def create_vlan_unit(iface: Interface, vlan_id: int) -> Interface: + """Create a unit / sub-interface on the given interface with the given 'vlan_id'. + + Parameters + ---------- + iface : Interface + Interface to create a VLAN unit on. + vlan_id: int + Numerical VLAN ID. + + Returns + ------- + Interface + Return a dcim.models.device_components.Interface object. + """ + unit_ifname = "%s.%d" % (iface.name, vlan_id) + + # Check if this unit already exists + try: + Interface.objects.get( + device=iface.device, + name=unit_ifname, + ) + raise InvalidInput( + f"VLAN ID {vlan_id} already configured on {get_port_string(iface)}" + ) + except Interface.DoesNotExist: + pass + + vlan_unit = Interface( + device=iface.device, + name=unit_ifname, + type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=iface, + ) + vlan_unit.save() + + return vlan_unit + + +def tag_interfaces(interfaces: list[Interface], tag_name: str) -> None: + """Ensure all given interfaces have the given tag associated with them. + + Parameters + ---------- + interfaces : list[Interface] + List of Interfaces to assure tag is on. + tag_name: str + Name of tag to apply to Interfaces. + """ + try: + tag_obj = Tag.objects.get(name=tag_name) + except Tag.DoesNotExist: + raise NetBoxDataError(f"Can't find tag {tag_name} - dying of shame.") + + for iface in interfaces: + tags = iface.tags.all() + if tag_name not in tags: + iface.tags.add(tag_obj) + + +def assign_IP_address_to_interface( + iface: Interface, + ip_str: str, + custom_fields: Optional[dict] = None, + script: Optional[Script] = None, +) -> None: + """Assign an IP address to an Interface. + + Parameters + ---------- + iface : Interface + Interface to assing IP on. + ip_str: str + String represenation of IP address. + custom_fields: dict, optional + Dictionary containing custom field values to apply to IP address. + script: Script, optional + Script object this function is called from, used for logigng. + """ + ips = IPAddress.objects.filter(address=ip_str, interface=iface) + if ips: + log_maybe( + script, + "info", + f"IP {ip_str} already assigned to {iface} on device {iface.device.name}", + ) + return + + ip_obj = IPAddress(address=ip_str) + ip_obj.save() + iface.ip_addresses.add(ip_obj) + iface.save() + + if custom_fields: + ip_obj.custom_field_data.update(custom_fields) + ip_obj.save() + + log_maybe( + script, + "success", + f"Assigned IP {ip_obj} to interface {iface} on device {iface.device.name}", + ) + + +################################################################################ +# IPAM related helper functions # +################################################################################ + + +def get_prefixes( + role_slug: str, + address_family: Literal[4, 6], + is_container: Optional[bool] = True, + custom_fields: Optional[dict] = None, + tag_name: Optional[str] = None, + vrf_name: Optional[str] = None, +) -> list[Prefix]: + """Get all prefixes fulfilling the given criteria (ANDed). + + Parameters + ---------- + role_slug : str + The slug of the prefix role to search for. + address_family : Literal[4, 6] + The address family of the prefix to search for. + is_container : Optional[bool] + If the prefix has to be have status container (default True) + custom_fields : Optional[dict] + Additional custom fields to set searching for prefixes. + tag_name : Optional[str] + The name of the tag to search for on the prefix (if any) + vrf_name : str + The vrf to search for. + + Returns + ------- + list[Prefix] + A list of ipam.models.Prefix objects. + """ + pfx_role = get_prefix_role(role_slug) + if pfx_role is None: + raise InvalidInput(f"Prefix role (slug) {role_slug} does not exist!") + + query_args = { + "role": pfx_role, + } + + if is_container: + query_args["status"]: PrefixStatusChoices.STATUS_CONTAINER + if tag_name is not None: + query_args["tags__name__in"] = [tag_name] + if custom_fields is not None: + query_args["custom_field_data"] = custom_fields + if vrf_name is not None: + vrf = get_vrf(vrf_name) + if vrf is None: + raise InvalidInput(f"VRF {vrf_name} does not exist!") + query_args["vrf__name"] = vrf.name + + pfxs = [] + + # Manually check for correct AF, seems we can't query for it + for pfx in Prefix.objects.filter(**query_args): + if pfx.family != address_family: + continue + + pfxs.append(pfx) + + return pfxs + + +def get_or_create_prefix( + prefix: Union[netaddr.ip.IPNetwork, str], + desc: str, + role: Optional[Role] = None, + script: Optional[Script] = None, +) -> tuple[Prefix, bool]: + """Make sure the given prefix exists and return it. + + If the prefix exist, the function checks if the description and role are identical + and issue a warning if they arent (if a script is given). + If the prefix does not exist, it will be created including the given description and role. + + The function will return the prefix and if it has been created. + + Parameters + ---------- + prefix : Union[netaddr.ip.IPNetwork, str] + The prefix to create/lookup, either as netaddr.ip.IPNetwork or string. + desc : str + The description to set on the prefix (if created) + role: Role, optional + The role to set on the prefix (if created) + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Prefix + Return a ipam.models.Prefix object. + bool + True if the prefix was created, False if it existed. + """ + pfx = netaddr.ip.IPNetwork(prefix) + + npfxs = Prefix.objects.filter(prefix=pfx) + + if len(npfxs) > 1: + raise NetBoxDataError(f"Multiple Prefixes found for {prefix}") + + elif len(npfxs) == 1: + existing_pfx = npfxs[0] + + if existing_pfx.description == desc and existing_pfx.role == role: + log_maybe(script, "info", f"Found existing prefix {existing_pfx}") + else: + if existing_pfx.description != desc: + log_maybe( + script, + "warning", + f"Found existing prefix {existing_pfx} with unexpected description: {existing_pfx.description}", + ) + if existing_pfx.role != role: + role_str = "" + if existing_pfx.role is not None: + role_str = existing_pfx.role.name + log_maybe( + script, + "warning", + f"Found existing prefix {existing_pfx} with unexpected role: {role_str}", + ) + + return existing_pfx, False + + kwargs = { + "prefix": pfx, + "description": desc, + } + if role is not None: + kwargs["role"] = role + + pfx = Prefix(**kwargs) + pfx.save() + log_maybe(script, "success", f"Created prefix {pfx} with description {desc}") + + return pfx, True + + +def get_or_create_prefix_from_ip( + ip_addr: Union[IPAddress, netaddr.ip.IPNetwork, str], + desc: str, + role: Optional[Role] = None, + script: Optional[Script] = None, +) -> tuple[Prefix, bool]: + """This wraps get_or_create_prefix() and derives the IP network of the given IP before calling get_or_create_prefix. + + Parameters + ---------- + ip_addr : Union[IPAddress, netaddr.ip.IPNetwork, str] + The IP to derive the prefix from. + desc : str + The description to set on the prefix (if created) + role: Role, optional + The role to set on the prefix (if created) + script: Script, optional + Script object this function is called from, used for logigng. + + Returns + ------- + Prefix + Return a ipam.models.Prefix object. + bool + True if the prefix was created, False if it existed. + """ + if isinstance(ip_addr, IPAddress): + ip_addr = str(ip_addr) + ip = netaddr.ip.IPNetwork(ip_addr) + pfx = f"{ip.network}/{ip.prefixlen}" + + return get_or_create_prefix(pfx, desc, role, script) + + +def get_prefix_role(slug: str) -> Optional[Role]: + """Return the prefix Role object for the given role slug or None if it doesn't exist. + + Parameters + ---------- + slug : str + Slug of the prefix role to query. + + Returns + ------- + Role or None + Returns an ipam.models.Role object, or None. + """ + try: + return Role.objects.get(slug=slug) + except Role.DoesNotExist: + return None + + +def get_interface_IPs(iface: Interface) -> list[IPAddress]: + """Retrieve all IPAddresses configured on the given interface. + + Parameters + ---------- + iface : Interface + Interface to query IPs of. + + Returns + ------- + list[IPAddress] + Returns a list of ipam.models.IPAddress objects. + """ + return list(IPAddress.objects.filter(interface=iface)) + + +def get_interface_IP(iface: Interface, ip: str) -> Optional[IPAddress]: + """Check if the given IPAddress is configured on the given Interface. + + If it is return it, if it is multiple times raise an NetBoxDataError, if it isn't return None. + + Parameters + ---------- + iface: Interface + Interface to get IP of. + ip: str + IP to check for. + + Returns + ------- + IPAddress or None + Returns an ipam.models.IPAddress object, or None. + """ + ips = IPAddress.objects.filter(address=ip, interface=iface) + + if not ips: + return None + + if len(ips) > 1: + raise NetBoxDataError( + f"Found IP {ip} assigned to interface {iface} on {iface.device.name} assigned multiple times!" + ) + + return ips[0] + + +def get_next_free_IP_from_pools( + pools: list[Prefix], + script: Optional[Script] = None, +) -> Optional[IPAddress]: + """Get the next free IPAddress from the given Prefix pool(s). + + Parameters + ---------- + pools : list[Prefix] + A list of ipam.models.Prefix objects, representing the pools(s) to carve from + script : Script, optional + The script object to use for logging (if desired) + + Returns + ------- + IPAddress, optional + An ipam.models.IPAddress object, or None + """ + for pfx in pools: + if not pfx.is_pool: + log_maybe( + script, + "warning", + "Should carve an IP from prefix {pfx}, which isn't a pool.", + ) + + ip_str = pfx.get_first_available_ip() + if ip_str is None: + log_maybe(script, "info", f"Pool {pfx} is depleted, moving on.") + continue + + plen = 32 if pfx.family == AF_IPv4 else 128 + free_ip = IPAddress(address=f"{ip_str.split('/')[0]}/{plen}") + free_ip.save() + return free_ip + + log_maybe( + script, + "warning", + "Looks like all pools are depleted *sniff*: " + + ", ".join([str(p) for p in pools]), + ) + return None + + +def get_next_free_prefix_from_prefixes( # noqa: C901 + containers: list[Prefix], + prefix_length: int, + description: str, + prefix_role_slug: Optional[str] = None, + is_pool: Optional[bool] = False, + custom_fields: Optional[dict] = None, + vrf_name: Optional[str] = None, +) -> Prefix: + """Get the next free prefix from the given Prefix container(s). + + If no Prefix role is found for the given prefix_role_slug this will raise an InvalidInput exception. + + If a prefix of prefix_length can't be carved from the given container(s), this will return None. + + Parameters + ---------- + containers : list[Prefix] + A list of ipam.models.Prefix objects, representing the container(s) to carve from + prefix_length : int + Prefix-length of the prefix to carve + description : str + The description for the new Prefix + prefix_role_slug : Optional[str] + Slug of the PrefixRole to assign to the new prefix (if any) + is_pool : Optional[bool] + Whether or not the new prefix shalt be a pool (default False) + custom_fields : Optional[dict] + Custom fields to set for the new prefix + script : Script + The script object to use for logging (if desired) + vrf_name: str + String representing the VRF name + + Returns + ------- + Prefix + An ipam.models.Prefix object, or None + """ + # Prepare parameters for the Prefix to create + new_prefix_args = { + "description": description, + "is_pool": is_pool, + } + + if prefix_role_slug is not None: + new_prefix_args["role"] = get_prefix_role(prefix_role_slug) + if new_prefix_args["role"] is None: + raise InvalidInput(f"Prefix role (slug) {prefix_role_slug} does not exist!") + + if custom_fields is not None: + new_prefix_args["custom_field_data"] = custom_fields + + if vrf_name is not None: + vrf = get_vrf(vrf_name) + if vrf is None: + raise InvalidInput(f"VRF {vrf_name} does not exist!") + + new_prefix_args["vrf"] = vrf + + for pfx in containers: + # We do not want to assign the whole container + if prefix_length == pfx.mask_length: + continue + + # Get a list of all available sub-prefixes (type IPNetwork) + avail_pfxs = pfx.get_available_prefixes().iter_cidrs() + for apfx in avail_pfxs: + if apfx.prefixlen > prefix_length: + continue + + new_prefix = Prefix( + prefix=IPNetwork("%s/%s" % (apfx.network, prefix_length)), + **new_prefix_args, + ) + new_prefix.save() + + return new_prefix + + return None + + +def get_next_free_prefix_from_container( + container_role_slug: str, + address_family: Literal[4, 6], + prefix_length: int, + description: str, + container_tag_name: Optional[str] = None, + container_custom_fields: Optional[str] = None, + prefix_role_slug: Optional[str] = None, + is_pool: Optional[bool] = False, + script: Optional[Script] = None, +) -> Prefix: + """Get the next free prefix from the available Prefix container(s) of the given role. + + This will look up prefix(es) with the given PrefixRole and optionally a tag + and carve out the next available prefix of the given prefix-length from it. + + If no PrefixRole is found for the given container_role_slug or pfx_role string, + the (optional) Tag doesn't exist, or no container prefixes are found, this will + raise a InvalidInput exception. + + Parameters + ---------- + container_role_slug : str + Slug of the PrefixRole to use looking up the container prefix(es) + address_family : Literal[4, 6]: + The address family of the prefix to search for + prefix_length : int + Prefix-length of the prefix to carve + description : str + The description for the new Prefix + container_tag_name : Optional[str] + Name of the Tag to use to filter container prefixes (if any) + container_custom_fields : Optional[dict] + The custom fields to filter the container by (if any) + prefix_role_slug : Optional[str] + Slug of the PrefixRole to assign to the new prefix (if any) + is_pool : Optional[bool] + Whether or not the new prefix shalt be a pool (default False) + script : Script + The script object to use for logging (if desired) + + Returns + ------- + Prefix + An ipam.models.Prefix object. + """ + containers = get_prefixes( + container_role_slug, + address_family=address_family, + is_container=True, + custom_fields=container_custom_fields, + tag_name=container_tag_name, + ) + if len(containers) == 0: + err_msg = f"No container prefix found for role (slug) {container_role_slug}" + if container_tag_name is not None: + err_msg += f" and tag {container_tag_name}" + if container_custom_fields is not None: + err_msg += f" and custom fields {container_custom_fields}" + raise InvalidInput(err_msg) + + msg = "Found container prefixes {}".format(", ".join(str(p) for p in containers)) + + new_prefix = get_next_free_prefix_from_prefixes( + containers, + prefix_length, + description, + prefix_role_slug, + is_pool, + ) + if new_prefix is None: + raise NetBoxDataError(f"{msg}, but no free prefix available *sniff*") + + log_maybe(script, "success", f"{msg}, created prefix {new_prefix}") + return new_prefix + + +def get_IPs_from_IPSet(ipset: IPSet, prefix_length: int) -> list[IPNetwork]: + """Get the IPs from an IPSet.""" + ips = [] + + for ip_addr in ipset: + ips.append(IPNetwork(f"{ip_addr}/{prefix_length}")) + + return ips + + +def get_vrf(vrf_name: str) -> Optional[VRF]: + """Get the VRF with the given name. + + This will return None, if no VRF with the given name exists. + + Parameters + ---------- + vrf_name : str + The name of the colo to look up. + + Returns + ------- + VRF + A ipam.models.VRF object, or None + """ + try: + return VRF.objects.get(name=vrf_name) + except VRF.DoesNotExist: + return None + + +################################################################################ +# Tag related helper functions # +################################################################################ + + +def get_tag(name: str) -> Optional[Tag]: + """Return the Tag with the given name, if it exists, or None if it doesn't. + + Parameters + ---------- + name : str + Name of the Tag to query for. + + Returns + ------- + Tag or None + Returns a extras.models import Tag object, or None. + """ + try: + return Tag.objects.get(name=name) + except Tag.DoesNotExist: + return None + + +def get_or_create_tag( + name: str, color: Optional[ColorChoices] = None +) -> tuple[Tag, bool]: + """Get a tag from the DB or create it if it's present. + + Check if a Tag with the given name exists in the DB an return it, + or, if it doesn't exist, create a Tag with the given name and color. + + Parameters + ---------- + name : str + Name of the Tag to query for. + color: ColorChoices, optional + Color to set to Tag, if it's created. + + Returns + ------- + Tag + Returns a extras.models import Tag object + bool + True if the tag was created, False if it existed. + """ + try: + return Tag.objects.get(name=name), False + except Tag.DoesNotExist: + pass + + # Create tag with given color + if color is not None: + tag_obj = Tag(name=name, color=color) + tag_obj.save() + return tag_obj, True + + # Create tag with default values + tag_obj = Tag(name=name) + tag_obj.save() + + return tag_obj, True diff --git a/scripts/helper_library/common/validators.py b/scripts/helper_library/common/validators.py new file mode 100644 index 0000000..9d1d65d --- /dev/null +++ b/scripts/helper_library/common/validators.py @@ -0,0 +1,163 @@ +#!/usr/bin/python3 + +"""Validators library for NetBox scripts.""" + +from typing import Union + +import netaddr +from dcim.models import Device, Interface +from scripts.common.errors import InvalidInput + +################################################################################ +# Validators for single values # +################################################################################ + + +def validate_IP(field: str, ip: str) -> None: + """Validate that the given ip is a valid IPv4 or IPv6 address. + + If it isn't raise an InvalidInput exception denoting the errornous value including the field name. + """ + try: + netaddr.IPAddress(ip) + except Exception: + raise InvalidInput( + f'Given value "{ip}" for parameter "{field}" is not a valid IP address!' + ) + + +def validate_prefix(field: str, pfx: str) -> None: + """Validate that the given pfx is a valid IPv4 or IPv6 address / prefix length. + + If it isn't raise an InvalidInput exception denoting the errornous value including the field name. + """ + try: + if "/" not in pfx: + raise ValueError() + netaddr.IPNetwork(pfx) + except Exception: + raise InvalidInput( + f'Given value "{pfx}" for parameter "{field}" is not a valid IP prefix!' + ) + + +def validate_device_name(field: str, device_name: str) -> None: + """Validate that the given device_name refers to an existing device in NetBox. + + If it isn't raise an InvalidInput exception denoting the errornous value including the field name. + """ + try: + Device.objects.get(name=device_name) + except Device.DoesNotExist: + raise InvalidInput( + f'Given device name "{device_name}" (paramter "{field}") does not exist!' + ) + + +def validate_VLAN_ID(field: str, vid_str: str) -> None: + """Validate that the given vid is a valid VLAN ID, meaning an integer value between 0 and 4096. + + If it isn't raise an InvalidInput exception denoting the errornous value including the field name. + """ + try: + vid = int(vid_str) + if vid < 1 or vid > 4096: + raise ValueError() + except ValueError: + raise InvalidInput( + f'Given VLAN ID "{vid_str}" (parameter "{field}") is not a valid VLAN ID!' + ) + + +def validate_bool(field: str, boolean: any) -> None: + """Validate that the given boolean is a valid boolean. + + If it isn't raise an InvalidInput exception denoting the errornous value including the field name. + """ + if not isinstance(boolean, bool): + raise InvalidInput( + f'Given value "{boolean} (parameter "{field}") is not a valid boolean!' + ) + + +def validate_ASN(field: str, asn_str: str) -> None: + """Validate that the given asn is a valid ASN, meaning an integer value between 1 and 2^32. + + If it isn't raise an InvalidInput Exception denoting the errornous value including the field name. + """ + try: + asn = int(asn_str) + if asn < 1 or asn > 2**32: + raise ValueError() + except ValueError: + raise InvalidInput( + f'Given ASN "{asn_str}" (parameter "{field}") is not a valid ASN!' + ) + + +VALIDATOR_MAP = { + "ip": validate_IP, + "prefix": validate_prefix, + "device_name": validate_device_name, + "vlan_id": validate_VLAN_ID, + "bool": validate_bool, + "asn": validate_ASN, +} + + +################################################################################ +# Validators to be applied manually # +################################################################################ + + +def validate_IP_within_subnet( + ip: Union[netaddr.ip.IPNetwork, str], pfx: Union[netaddr.ip.IPNetwork, str] +) -> None: + """Validate that the given ip is a valid IPv4 or IPv6 address and lies within the given pfx. + + If ip or pfx aren't valid values or the ip doesn't lie within pfx, raise an InvalidInput exception. + """ + if isinstance(pfx, str) and "/" not in pfx: + raise InvalidInput(f"Invalid prefix {pfx}, no / present!") + try: + ip_obj = netaddr.IPAddress(ip) + pfx_obj = netaddr.IPNetwork(pfx) + except Exception: + raise InvalidInput( + f"Failed to parse IP {ip} or prefix {pfx}, while validating subnet alignment!" + ) + + if ip_obj not in pfx_obj: + raise InvalidInput(f"IP address {ip} does not belong to subnet {pfx}") + + +def validate_prefixes_within_same_subnet( + ip_a: Union[netaddr.ip.IPNetwork, str], ip_b: Union[netaddr.ip.IPNetwork, str] +) -> None: + """Verify the two IPs are within the same IP subnet. + + If they are NOT within the same subnet or at least one of them fails to parse, raise an InvalidInput exception. + """ + try: + ip_a_obj = netaddr.IPNetwork(ip_a) + ip_b_obj = netaddr.IPNetwork(ip_b) + except Exception: + raise InvalidInput( + f"At least one of IPs/prefixes {ip_a} / {ip_b} is not a valid CIDR prefix!" + ) + + if ip_a_obj.network != ip_b_obj.network or ip_a_obj.prefixlen != ip_b_obj.prefixlen: + raise InvalidInput( + f"IPs/Prefixes {ip_a} and {ip_b} are not part of the same subnet!" + ) + + +def validate_device_interface(device: Device, iface: Interface) -> None: + """Verify the given interface belongs to the given device. + + If not an InvalidInput exception is raised. + """ + if iface.device != device: + raise InvalidInput( + f"Interface {iface.name} does not belong to device {device.name}!" + ) diff --git a/scripts/helper_library/dev-testing-requirements.txt b/scripts/helper_library/dev-testing-requirements.txt new file mode 100644 index 0000000..1398dd2 --- /dev/null +++ b/scripts/helper_library/dev-testing-requirements.txt @@ -0,0 +1,3 @@ +black==23.9.1 +pytest==7.4.2 +ruff==0.1.1 \ No newline at end of file diff --git a/scripts/helper_library/docker-compose.test.yml b/scripts/helper_library/docker-compose.test.yml new file mode 100644 index 0000000..36916fc --- /dev/null +++ b/scripts/helper_library/docker-compose.test.yml @@ -0,0 +1,52 @@ +version: '2.4' + +services: + netbox_postgres: + # FIXME + image: docker.io/postgres:15-alpine + container_name: netbox_postgres_test + environment: + POSTGRES_USER: netbox + POSTGRES_PASSWORD: thaeGhu9iem+e + POSTGRES_DB: netbox + networks: + provision: + aliases: + - netboxpostgres + + netbox: + build: + context: . + links: + - netbox_postgres + container_name: netbox_test + networks: + - provision + ports: + - ${NETBOX_PORT:-8080}:${NETBOX_PORT:-8080} + depends_on: + - netbox_postgres + entrypoint: bash /opt/netbox/docker-test.sh + environment: + DEBUG: "true" + PORT: ${NETBOX_PORT:-8080} + SUPERUSER_NAME: admin + SUPERUSER_EMAIL: admin@example.com + SUPERUSER_PASSWORD: admin + ALLOWED_HOSTS: localhost netbox + DB_NAME: netbox + DB_USER: netbox + DB_HOST: netboxpostgres + APP_ENV: dev + DB_PASSWORD: thaeGhu9iem+e + SECRET_KEY: crMAOw9S5oyRrQ2Xyszg_s5lL#8&!Uwtk-rB4eQpTGyHtyQwln + volumes: + - ./common/:/opt/netbox/netbox/scripts/common/:rw + - ./tests/:/opt/netbox/netbox/scripts/tests/:ro + - ./docker-test.sh:/opt/netbox/docker-test.sh + - ./fixtures/:/opt/fixtures/ + +networks: + provision: + name: ${NETBOX_DOCKER_NETWORK:-provision} + driver: bridge diff --git a/scripts/helper_library/docker-test.sh b/scripts/helper_library/docker-test.sh new file mode 100644 index 0000000..81b2415 --- /dev/null +++ b/scripts/helper_library/docker-test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +/opt/netbox/netbox/manage.py migrate + +cd /tmp + +# Run the test suite and generate a coverage report +# +# Note: You can add --keepdb here so the DB isn't thrown away and created on each run. +# This isn't part of the default call as this may to lead false results in some cases, +# however it yield as massive speed-up when activated, which is save for most tests. +coverage run --source='/opt/netbox/netbox/scripts/' --omit='/opt/netbox/netbox/scripts/tests/*' /opt/netbox/netbox/manage.py test --noinput /opt/netbox/netbox/scripts/tests/ +coverage html -d /tmp/.cover diff --git a/scripts/helper_library/fixtures/README.md b/scripts/helper_library/fixtures/README.md new file mode 100644 index 0000000..95b7269 --- /dev/null +++ b/scripts/helper_library/fixtures/README.md @@ -0,0 +1,106 @@ +# Fixtures + +This fixtures directory will be present as `/opt/fixtures/` within the NetBox container used for tests. + +You can load any fixture(s) in your test by means of (e.g.) + + class ScriptTestCase(TestCase): + fixtures = [ + "/opt/fixtures/topology.json", + ] + +## Getting fixtures from the DB + +To remove the burden of re-creating the whole toplogy including DeviceType templates etc. for the tests, +we're using fixtures exported from a running Netbox instance. + +To query fixtures from your NetBox instance (assuming you're using NetBox-docker), do the following: + + docker exec -ti netbox /bin/bash -l + cd netbox/ + + # Templates + base line + ./manage.py dumpdata \ + circuits.circuittype \ + circuits.provider \ + dcim.region \ + dcim.site \ + dcim.manufacturer \ + dcim.devicetype \ + dcim.moduletype \ + dcim.modulebaytemplate \ + dcim.interfacetemplate \ + dcim.frontporttemplate \ + dcim.rearporttemplate \ + dcim.consoleporttemplate \ + dcim.consoleserverporttemplate \ + dcim.devicerole \ + dcim.platform \ + ipam.role \ + > templatedata.json + +and transform this file into something readable / diffable via + + jq < templatedata.json > templates.json + +Note that extras.customfield are not dumped, the one required custom field is created manually. + +## templates.json + +This includes (but is not limited to) the following models including some items commonly seen in networks and to provide a base line for running the unit tests: + +### Organizational models + +Sites + * DC01 + * DC02 + * NET-META-ANY + +### Circuit Models + +Circuit Types + * Dark Fiber + +Circuit Providers + * Provider1 + +### DCIM models + +**Manufacturers** + * Arista + * Cisco + * Common + * Juniper + * Mellanox + +**Device Types** (including Interface, Front + Rear Port templates) + * Common + * 24-port LC/LC PP + * Juniper + * QFX10008 + * Mellanox + * SN2010 + +**Module Types** (including Interface, Front + Rear Port templates) + * Juniper + * QFX10000-30C + * QFX10000-36Q + +**Platforms (slugs)** + * eos + * junos + * junos-evo + +**Device Roles (slugs)** + * console-server + * edge-router + * patch-panel + * peer + * pe + +### IPAM models + +**Roles** + * Loopback IPs + * Transfer Network + * Site Local \ No newline at end of file diff --git a/scripts/helper_library/fixtures/templates.json b/scripts/helper_library/fixtures/templates.json new file mode 100644 index 0000000..0d2647f --- /dev/null +++ b/scripts/helper_library/fixtures/templates.json @@ -0,0 +1,3564 @@ +[ + { + "model": "circuits.circuittype", + "pk": 1, + "fields": { + "created": "2023-03-21T12:24:31.852Z", + "last_updated": "2023-10-30T19:47:44.241Z", + "custom_field_data": {}, + "name": "Dark Fiber", + "slug": "dark-fiber", + "description": "" + } + }, + { + "model": "circuits.provider", + "pk": 2, + "fields": { + "created": "2023-03-21T12:28:17.916Z", + "last_updated": "2023-10-30T19:46:46.154Z", + "custom_field_data": {}, + "name": "Provider1", + "slug": "provider1", + "asn": null, + "account": "", + "portal_url": "", + "noc_contact": "", + "admin_contact": "", + "comments": "", + "asns": [] + } + }, + { + "model": "dcim.region", + "pk": 1, + "fields": { + "created": "2019-11-04T00:00:00Z", + "last_updated": "2023-03-21T10:50:15.476Z", + "custom_field_data": { + "region_type": null + }, + "parent": 9, + "name": "Northern America", + "slug": "northern-america", + "description": "", + "lft": 2, + "rght": 9, + "tree_id": 4, + "level": 1 + } + }, + { + "model": "dcim.region", + "pk": 2, + "fields": { + "created": "2019-11-04T00:00:00Z", + "last_updated": "2019-11-04T19:34:47.123Z", + "custom_field_data": { + "region_type": null + }, + "parent": 1, + "name": "West North America", + "slug": "wnam", + "description": "", + "lft": 7, + "rght": 8, + "tree_id": 4, + "level": 2 + } + }, + { + "model": "dcim.region", + "pk": 3, + "fields": { + "created": "2019-11-04T00:00:00Z", + "last_updated": "2019-11-04T19:35:48.865Z", + "custom_field_data": { + "region_type": null + }, + "parent": 1, + "name": "East North America", + "slug": "enam", + "description": "", + "lft": 3, + "rght": 4, + "tree_id": 4, + "level": 2 + } + }, + { + "model": "dcim.region", + "pk": 4, + "fields": { + "created": "2019-11-04T00:00:00Z", + "last_updated": "2019-11-04T19:38:54.482Z", + "custom_field_data": { + "region_type": null + }, + "parent": null, + "name": "Europe", + "slug": "eur", + "description": "", + "lft": 1, + "rght": 4, + "tree_id": 3, + "level": 0 + } + }, + { + "model": "dcim.region", + "pk": 5, + "fields": { + "created": "2019-11-04T00:00:00Z", + "last_updated": "2019-11-04T19:39:53.660Z", + "custom_field_data": { + "region_type": null + }, + "parent": 4, + "name": "Western Europe", + "slug": "weur", + "description": "", + "lft": 2, + "rght": 3, + "tree_id": 3, + "level": 1 + } + }, + { + "model": "dcim.region", + "pk": 8, + "fields": { + "created": "2023-03-21T10:26:49.477Z", + "last_updated": "2023-03-21T10:26:49.477Z", + "custom_field_data": { + "region_type": null + }, + "parent": null, + "name": "NET-META-ANY", + "slug": "net-meta-any", + "description": "", + "lft": 1, + "rght": 2, + "tree_id": 5, + "level": 0 + } + }, + { + "model": "dcim.region", + "pk": 9, + "fields": { + "created": "2023-03-21T10:50:03.707Z", + "last_updated": "2023-03-21T10:50:03.707Z", + "custom_field_data": { + "region_type": null + }, + "parent": null, + "name": "NAM", + "slug": "nam", + "description": "", + "lft": 1, + "rght": 10, + "tree_id": 4, + "level": 0 + } + }, + { + "model": "dcim.region", + "pk": 10, + "fields": { + "created": "2023-03-21T10:50:33.037Z", + "last_updated": "2023-03-21T10:50:33.037Z", + "custom_field_data": { + "region_type": null + }, + "parent": 1, + "name": "United States", + "slug": "united-states", + "description": "", + "lft": 5, + "rght": 6, + "tree_id": 4, + "level": 2 + } + }, + { + "model": "dcim.site", + "pk": 5, + "fields": { + "created": "2023-03-21T10:31:05.443Z", + "last_updated": "2023-03-21T10:31:05.443Z", + "custom_field_data": { + "colo_id": null + }, + "name": "NET-META-ANY", + "_name": "NET-META-ANY", + "slug": "net-meta-any", + "status": "active", + "region": 8, + "group": null, + "tenant": null, + "facility": "", + "time_zone": null, + "description": "A NET meta site in the NET-META-ANY region, accessible from anywhere", + "physical_address": "", + "shipping_address": "", + "latitude": null, + "longitude": null, + "comments": "", + "asns": [] + } + }, + { + "model": "dcim.site", + "pk": 6, + "fields": { + "created": "2023-03-21T10:50:51.957Z", + "last_updated": "2023-10-30T19:43:41.731Z", + "custom_field_data": { + "colo_id": null + }, + "name": "DC02", + "_name": "DC00000002", + "slug": "dc02", + "status": "active", + "region": 10, + "group": null, + "tenant": null, + "facility": "", + "time_zone": null, + "description": "", + "physical_address": "", + "shipping_address": "", + "latitude": null, + "longitude": null, + "comments": "", + "asns": [] + } + }, + { + "model": "dcim.site", + "pk": 7, + "fields": { + "created": "2023-03-29T15:24:19.991Z", + "last_updated": "2023-10-30T19:43:29.614Z", + "custom_field_data": { + "colo_id": null + }, + "name": "DC01", + "_name": "DC00000001", + "slug": "dc01", + "status": "active", + "region": 10, + "group": null, + "tenant": null, + "facility": "", + "time_zone": null, + "description": "", + "physical_address": "", + "shipping_address": "", + "latitude": null, + "longitude": null, + "comments": "", + "asns": [] + } + }, + { + "model": "dcim.manufacturer", + "pk": 4, + "fields": { + "created": "2023-03-21T10:20:46.729Z", + "last_updated": "2023-10-30T19:11:15.490Z", + "custom_field_data": {}, + "name": "Common", + "slug": "common", + "description": "" + } + }, + { + "model": "dcim.manufacturer", + "pk": 5, + "fields": { + "created": "2023-03-21T10:36:52.059Z", + "last_updated": "2023-03-21T10:36:52.059Z", + "custom_field_data": {}, + "name": "Mellanox", + "slug": "mellanox", + "description": "" + } + }, + { + "model": "dcim.manufacturer", + "pk": 6, + "fields": { + "created": "2023-03-29T15:18:21.233Z", + "last_updated": "2023-03-29T15:18:21.233Z", + "custom_field_data": {}, + "name": "Juniper", + "slug": "juniper", + "description": "" + } + }, + { + "model": "dcim.manufacturer", + "pk": 10, + "fields": { + "created": "2023-07-18T14:34:13.407Z", + "last_updated": "2023-07-18T14:34:13.407Z", + "custom_field_data": {}, + "name": "Cisco", + "slug": "cisco", + "description": "" + } + }, + { + "model": "dcim.manufacturer", + "pk": 12, + "fields": { + "created": "2023-10-31T10:47:07.728Z", + "last_updated": "2023-10-31T10:47:07.728Z", + "custom_field_data": {}, + "name": "Arista", + "slug": "arista", + "description": "" + } + }, + { + "model": "dcim.devicetype", + "pk": 15, + "fields": { + "created": "2023-03-21T10:37:10.661Z", + "last_updated": "2023-03-29T10:18:29.383Z", + "custom_field_data": {}, + "manufacturer": 5, + "model": "SN2010", + "slug": "sn2010", + "part_number": "MSN2010", + "u_height": 1, + "is_full_depth": false, + "subdevice_role": "", + "airflow": "", + "front_image": "", + "rear_image": "", + "comments": "" + } + }, + { + "model": "dcim.devicetype", + "pk": 16, + "fields": { + "created": "2023-03-29T15:18:46.053Z", + "last_updated": "2023-03-29T15:18:46.053Z", + "custom_field_data": {}, + "manufacturer": 6, + "model": "QFX10008", + "slug": "qfx10008", + "part_number": "", + "u_height": 13, + "is_full_depth": true, + "subdevice_role": "", + "airflow": "", + "front_image": "", + "rear_image": "", + "comments": "" + } + }, + { + "model": "dcim.devicetype", + "pk": 23, + "fields": { + "created": "2023-07-18T14:34:41.512Z", + "last_updated": "2023-07-18T14:34:41.512Z", + "custom_field_data": {}, + "manufacturer": 10, + "model": "ISR4331", + "slug": "isr4331", + "part_number": "", + "u_height": 1, + "is_full_depth": false, + "subdevice_role": "", + "airflow": "", + "front_image": "", + "rear_image": "", + "comments": "" + } + }, + { + "model": "dcim.devicetype", + "pk": 24, + "fields": { + "created": "2023-07-18T15:18:32.543Z", + "last_updated": "2023-10-31T13:02:20.310Z", + "custom_field_data": {}, + "manufacturer": 4, + "model": "24-port LC/LC PP", + "slug": "24-port-lclc-pp", + "part_number": "", + "u_height": 1, + "is_full_depth": false, + "subdevice_role": "", + "airflow": "", + "front_image": "", + "rear_image": "", + "comments": "" + } + }, + { + "model": "dcim.devicetype", + "pk": 25, + "fields": { + "created": "2023-10-30T19:11:05.209Z", + "last_updated": "2023-10-31T10:45:48.114Z", + "custom_field_data": {}, + "manufacturer": 4, + "model": "NET-META-Peer", + "slug": "net-meta-peer", + "part_number": "", + "u_height": 0, + "is_full_depth": false, + "subdevice_role": "", + "airflow": "", + "front_image": "", + "rear_image": "", + "comments": "" + } + }, + { + "model": "dcim.moduletype", + "pk": 1, + "fields": { + "created": "2023-03-29T15:20:46.204Z", + "last_updated": "2023-03-29T15:20:46.204Z", + "custom_field_data": {}, + "manufacturer": 6, + "model": "QFX10000-30C", + "part_number": "750-051357", + "comments": "" + } + }, + { + "model": "dcim.moduletype", + "pk": 2, + "fields": { + "created": "2023-03-29T15:21:25.899Z", + "last_updated": "2023-03-29T15:21:25.899Z", + "custom_field_data": {}, + "manufacturer": 6, + "model": "QFX10000-36Q", + "part_number": "750-068822", + "comments": "" + } + }, + { + "model": "dcim.moduletype", + "pk": 10, + "fields": { + "created": "2023-07-18T14:35:28.647Z", + "last_updated": "2023-07-18T14:35:28.647Z", + "custom_field_data": {}, + "manufacturer": 10, + "model": "NIM-24A", + "part_number": "", + "comments": "" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 1, + "fields": { + "created": "2023-03-29T15:19:02.790Z", + "last_updated": "2023-03-29T15:19:02.790Z", + "device_type": 16, + "name": "CB0", + "_name": "CB00000000", + "label": "", + "description": "", + "position": "" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 2, + "fields": { + "created": "2023-03-29T15:19:02.797Z", + "last_updated": "2023-03-29T15:19:02.797Z", + "device_type": 16, + "name": "CB1", + "_name": "CB00000001", + "label": "", + "description": "", + "position": "" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 3, + "fields": { + "created": "2023-03-29T15:19:16.747Z", + "last_updated": "2023-03-29T15:19:16.747Z", + "device_type": 16, + "name": "FPC0", + "_name": "FPC00000000", + "label": "", + "description": "", + "position": "0" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 4, + "fields": { + "created": "2023-03-29T15:19:16.755Z", + "last_updated": "2023-03-29T15:19:16.755Z", + "device_type": 16, + "name": "FPC1", + "_name": "FPC00000001", + "label": "", + "description": "", + "position": "1" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 5, + "fields": { + "created": "2023-03-29T15:19:16.758Z", + "last_updated": "2023-03-29T15:19:16.758Z", + "device_type": 16, + "name": "FPC2", + "_name": "FPC00000002", + "label": "", + "description": "", + "position": "2" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 6, + "fields": { + "created": "2023-03-29T15:19:16.760Z", + "last_updated": "2023-03-29T15:19:16.761Z", + "device_type": 16, + "name": "FPC3", + "_name": "FPC00000003", + "label": "", + "description": "", + "position": "3" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 7, + "fields": { + "created": "2023-03-29T15:19:16.763Z", + "last_updated": "2023-03-29T15:19:16.763Z", + "device_type": 16, + "name": "FPC4", + "_name": "FPC00000004", + "label": "", + "description": "", + "position": "4" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 8, + "fields": { + "created": "2023-03-29T15:19:16.765Z", + "last_updated": "2023-03-29T15:19:16.765Z", + "device_type": 16, + "name": "FPC5", + "_name": "FPC00000005", + "label": "", + "description": "", + "position": "5" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 9, + "fields": { + "created": "2023-03-29T15:19:16.767Z", + "last_updated": "2023-03-29T15:19:16.767Z", + "device_type": 16, + "name": "FPC6", + "_name": "FPC00000006", + "label": "", + "description": "", + "position": "6" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 10, + "fields": { + "created": "2023-03-29T15:19:16.769Z", + "last_updated": "2023-03-29T15:19:16.769Z", + "device_type": 16, + "name": "FPC7", + "_name": "FPC00000007", + "label": "", + "description": "", + "position": "7" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 26, + "fields": { + "created": "2023-07-18T14:34:41.565Z", + "last_updated": "2023-07-18T14:34:41.565Z", + "device_type": 23, + "name": "0/1", + "_name": "00000000/00000001", + "label": "", + "description": "", + "position": "0/1" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 27, + "fields": { + "created": "2023-07-18T14:34:41.570Z", + "last_updated": "2023-07-18T14:34:41.570Z", + "device_type": 23, + "name": "0/2", + "_name": "00000000/00000002", + "label": "", + "description": "", + "position": "0/2" + } + }, + { + "model": "dcim.modulebaytemplate", + "pk": 28, + "fields": { + "created": "2023-07-18T14:34:41.575Z", + "last_updated": "2023-07-18T14:34:41.575Z", + "device_type": 23, + "name": "1/0", + "_name": "00000001/00000000", + "label": "", + "description": "", + "position": "1/0" + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 38, + "fields": { + "created": "2023-03-21T10:37:26.564Z", + "last_updated": "2023-10-31T11:37:09.917Z", + "name": "Ethernet1", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000001............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 39, + "fields": { + "created": "2023-03-21T10:37:26.571Z", + "last_updated": "2023-10-31T11:37:09.939Z", + "name": "Ethernet2", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000002............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 40, + "fields": { + "created": "2023-03-21T10:37:26.574Z", + "last_updated": "2023-10-31T11:37:09.943Z", + "name": "Ethernet3", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000003............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 41, + "fields": { + "created": "2023-03-21T10:37:26.578Z", + "last_updated": "2023-10-31T11:37:09.949Z", + "name": "Ethernet4", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000004............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 42, + "fields": { + "created": "2023-03-21T10:37:26.581Z", + "last_updated": "2023-10-31T11:37:09.956Z", + "name": "Ethernet5", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000005............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 43, + "fields": { + "created": "2023-03-21T10:37:26.585Z", + "last_updated": "2023-10-31T11:37:09.966Z", + "name": "Ethernet6", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000006............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 44, + "fields": { + "created": "2023-03-21T10:37:26.590Z", + "last_updated": "2023-10-31T11:37:09.975Z", + "name": "Ethernet7", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000007............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 45, + "fields": { + "created": "2023-03-21T10:37:26.595Z", + "last_updated": "2023-10-31T11:37:09.984Z", + "name": "Ethernet8", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000008............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 46, + "fields": { + "created": "2023-03-21T10:37:26.598Z", + "last_updated": "2023-10-31T11:37:09.993Z", + "name": "Ethernet9", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000009............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 47, + "fields": { + "created": "2023-03-21T10:37:26.601Z", + "last_updated": "2023-10-31T11:37:10.000Z", + "name": "Ethernet10", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000010............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 48, + "fields": { + "created": "2023-03-21T10:37:26.604Z", + "last_updated": "2023-10-31T11:37:10.004Z", + "name": "Ethernet11", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000011............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 49, + "fields": { + "created": "2023-03-21T10:37:26.607Z", + "last_updated": "2023-10-31T11:37:10.007Z", + "name": "Ethernet12", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000012............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 50, + "fields": { + "created": "2023-03-21T10:37:26.609Z", + "last_updated": "2023-10-31T11:37:10.011Z", + "name": "Ethernet13", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000013............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 51, + "fields": { + "created": "2023-03-21T10:37:26.612Z", + "last_updated": "2023-10-31T11:37:10.015Z", + "name": "Ethernet14", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000014............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 52, + "fields": { + "created": "2023-03-21T10:37:26.615Z", + "last_updated": "2023-10-31T11:37:10.019Z", + "name": "Ethernet15", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000015............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 53, + "fields": { + "created": "2023-03-21T10:37:26.617Z", + "last_updated": "2023-10-31T11:37:10.023Z", + "name": "Ethernet16", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000016............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 54, + "fields": { + "created": "2023-03-21T10:37:26.619Z", + "last_updated": "2023-10-31T11:37:10.027Z", + "name": "Ethernet17", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000017............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 55, + "fields": { + "created": "2023-03-21T10:37:26.622Z", + "last_updated": "2023-10-31T11:37:10.032Z", + "name": "Ethernet18", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000018............", + "type": "25gbase-x-sfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 56, + "fields": { + "created": "2023-03-21T10:37:37.444Z", + "last_updated": "2023-10-31T11:37:10.036Z", + "name": "Ethernet19", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000019............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 57, + "fields": { + "created": "2023-03-21T10:37:37.451Z", + "last_updated": "2023-10-31T11:37:10.039Z", + "name": "Ethernet20", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000020............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 58, + "fields": { + "created": "2023-03-21T10:37:37.454Z", + "last_updated": "2023-10-31T11:37:10.043Z", + "name": "Ethernet21", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000021............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 59, + "fields": { + "created": "2023-03-21T10:37:37.456Z", + "last_updated": "2023-10-31T11:37:10.048Z", + "name": "Ethernet22", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999Ethernet000022............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 60, + "fields": { + "created": "2023-03-21T10:37:44.530Z", + "last_updated": "2023-10-31T11:37:10.051Z", + "name": "lo", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999lo..................", + "type": "virtual", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 61, + "fields": { + "created": "2023-03-21T10:37:52.764Z", + "last_updated": "2023-10-31T11:37:10.055Z", + "name": "mgmt", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "_name": "9999999999999999mgmt..................", + "type": "1000base-t", + "mgmt_only": true + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 62, + "fields": { + "created": "2023-03-29T15:21:09.797Z", + "last_updated": "2023-03-29T15:21:09.797Z", + "name": "et-{module}/0/0", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000000............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 63, + "fields": { + "created": "2023-03-29T15:21:09.801Z", + "last_updated": "2023-03-29T15:21:09.801Z", + "name": "et-{module}/0/1", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000001............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 64, + "fields": { + "created": "2023-03-29T15:21:09.804Z", + "last_updated": "2023-03-29T15:21:09.804Z", + "name": "et-{module}/0/2", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000002............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 65, + "fields": { + "created": "2023-03-29T15:21:09.808Z", + "last_updated": "2023-03-29T15:21:09.808Z", + "name": "et-{module}/0/3", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000003............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 66, + "fields": { + "created": "2023-03-29T15:21:09.810Z", + "last_updated": "2023-03-29T15:21:09.810Z", + "name": "et-{module}/0/4", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000004............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 67, + "fields": { + "created": "2023-03-29T15:21:09.813Z", + "last_updated": "2023-03-29T15:21:09.813Z", + "name": "et-{module}/0/5", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000005............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 68, + "fields": { + "created": "2023-03-29T15:21:09.816Z", + "last_updated": "2023-03-29T15:21:09.816Z", + "name": "et-{module}/0/6", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000006............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 69, + "fields": { + "created": "2023-03-29T15:21:09.818Z", + "last_updated": "2023-03-29T15:21:09.818Z", + "name": "et-{module}/0/7", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000007............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 70, + "fields": { + "created": "2023-03-29T15:21:09.821Z", + "last_updated": "2023-03-29T15:21:09.821Z", + "name": "et-{module}/0/8", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000008............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 71, + "fields": { + "created": "2023-03-29T15:21:09.824Z", + "last_updated": "2023-03-29T15:21:09.824Z", + "name": "et-{module}/0/9", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000009............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 72, + "fields": { + "created": "2023-03-29T15:21:09.826Z", + "last_updated": "2023-03-29T15:21:09.826Z", + "name": "et-{module}/0/10", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000010............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 73, + "fields": { + "created": "2023-03-29T15:21:09.829Z", + "last_updated": "2023-03-29T15:21:09.829Z", + "name": "et-{module}/0/11", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000011............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 74, + "fields": { + "created": "2023-03-29T15:21:09.831Z", + "last_updated": "2023-03-29T15:21:09.831Z", + "name": "et-{module}/0/12", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000012............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 75, + "fields": { + "created": "2023-03-29T15:21:09.834Z", + "last_updated": "2023-03-29T15:21:09.834Z", + "name": "et-{module}/0/13", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000013............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 76, + "fields": { + "created": "2023-03-29T15:21:09.836Z", + "last_updated": "2023-03-29T15:21:09.836Z", + "name": "et-{module}/0/14", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000014............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 77, + "fields": { + "created": "2023-03-29T15:21:09.839Z", + "last_updated": "2023-03-29T15:21:09.839Z", + "name": "et-{module}/0/15", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000015............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 78, + "fields": { + "created": "2023-03-29T15:21:09.842Z", + "last_updated": "2023-03-29T15:21:09.842Z", + "name": "et-{module}/0/16", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000016............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 79, + "fields": { + "created": "2023-03-29T15:21:09.844Z", + "last_updated": "2023-03-29T15:21:09.844Z", + "name": "et-{module}/0/17", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000017............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 80, + "fields": { + "created": "2023-03-29T15:21:09.847Z", + "last_updated": "2023-03-29T15:21:09.847Z", + "name": "et-{module}/0/18", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000018............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 81, + "fields": { + "created": "2023-03-29T15:21:09.849Z", + "last_updated": "2023-03-29T15:21:09.849Z", + "name": "et-{module}/0/19", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000019............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 82, + "fields": { + "created": "2023-03-29T15:21:09.851Z", + "last_updated": "2023-03-29T15:21:09.851Z", + "name": "et-{module}/0/20", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000020............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 83, + "fields": { + "created": "2023-03-29T15:21:09.856Z", + "last_updated": "2023-03-29T15:21:09.856Z", + "name": "et-{module}/0/21", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000021............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 84, + "fields": { + "created": "2023-03-29T15:21:09.859Z", + "last_updated": "2023-03-29T15:21:09.859Z", + "name": "et-{module}/0/22", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000022............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 85, + "fields": { + "created": "2023-03-29T15:21:09.862Z", + "last_updated": "2023-03-29T15:21:09.862Z", + "name": "et-{module}/0/23", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000023............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 86, + "fields": { + "created": "2023-03-29T15:21:09.864Z", + "last_updated": "2023-03-29T15:21:09.865Z", + "name": "et-{module}/0/24", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000024............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 87, + "fields": { + "created": "2023-03-29T15:21:09.867Z", + "last_updated": "2023-03-29T15:21:09.867Z", + "name": "et-{module}/0/25", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000025............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 88, + "fields": { + "created": "2023-03-29T15:21:09.869Z", + "last_updated": "2023-03-29T15:21:09.869Z", + "name": "et-{module}/0/26", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000026............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 89, + "fields": { + "created": "2023-03-29T15:21:09.873Z", + "last_updated": "2023-03-29T15:21:09.873Z", + "name": "et-{module}/0/27", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000027............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 90, + "fields": { + "created": "2023-03-29T15:21:09.876Z", + "last_updated": "2023-03-29T15:21:09.876Z", + "name": "et-{module}/0/28", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000028............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 91, + "fields": { + "created": "2023-03-29T15:21:09.879Z", + "last_updated": "2023-03-29T15:21:09.879Z", + "name": "et-{module}/0/29", + "label": "", + "description": "", + "device_type": null, + "module_type": 1, + "_name": "0000999999999999et-{module}/000029............", + "type": "100gbase-x-qsfp28", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 92, + "fields": { + "created": "2023-03-29T15:22:00.467Z", + "last_updated": "2023-03-29T15:22:00.467Z", + "name": "et-{module}/0/0", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000000............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 93, + "fields": { + "created": "2023-03-29T15:22:00.475Z", + "last_updated": "2023-03-29T15:22:00.475Z", + "name": "et-{module}/0/1", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000001............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 94, + "fields": { + "created": "2023-03-29T15:22:00.478Z", + "last_updated": "2023-03-29T15:22:00.478Z", + "name": "et-{module}/0/2", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000002............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 95, + "fields": { + "created": "2023-03-29T15:22:00.481Z", + "last_updated": "2023-03-29T15:22:00.481Z", + "name": "et-{module}/0/3", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000003............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 96, + "fields": { + "created": "2023-03-29T15:22:00.484Z", + "last_updated": "2023-03-29T15:22:00.484Z", + "name": "et-{module}/0/4", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000004............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 97, + "fields": { + "created": "2023-03-29T15:22:00.487Z", + "last_updated": "2023-03-29T15:22:00.487Z", + "name": "et-{module}/0/5", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000005............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 98, + "fields": { + "created": "2023-03-29T15:22:00.489Z", + "last_updated": "2023-03-29T15:22:00.489Z", + "name": "et-{module}/0/6", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000006............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 99, + "fields": { + "created": "2023-03-29T15:22:00.491Z", + "last_updated": "2023-03-29T15:22:00.491Z", + "name": "et-{module}/0/7", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000007............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 100, + "fields": { + "created": "2023-03-29T15:22:00.494Z", + "last_updated": "2023-03-29T15:22:00.494Z", + "name": "et-{module}/0/8", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000008............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 101, + "fields": { + "created": "2023-03-29T15:22:00.498Z", + "last_updated": "2023-03-29T15:22:00.498Z", + "name": "et-{module}/0/9", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000009............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 102, + "fields": { + "created": "2023-03-29T15:22:00.501Z", + "last_updated": "2023-03-29T15:22:00.501Z", + "name": "et-{module}/0/10", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000010............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 103, + "fields": { + "created": "2023-03-29T15:22:00.503Z", + "last_updated": "2023-03-29T15:22:00.503Z", + "name": "et-{module}/0/11", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000011............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 104, + "fields": { + "created": "2023-03-29T15:22:00.506Z", + "last_updated": "2023-03-29T15:22:00.506Z", + "name": "et-{module}/0/12", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000012............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 105, + "fields": { + "created": "2023-03-29T15:22:00.508Z", + "last_updated": "2023-03-29T15:22:00.508Z", + "name": "et-{module}/0/13", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000013............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 106, + "fields": { + "created": "2023-03-29T15:22:00.511Z", + "last_updated": "2023-03-29T15:22:00.511Z", + "name": "et-{module}/0/14", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000014............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 107, + "fields": { + "created": "2023-03-29T15:22:00.514Z", + "last_updated": "2023-03-29T15:22:00.514Z", + "name": "et-{module}/0/15", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000015............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 108, + "fields": { + "created": "2023-03-29T15:22:00.516Z", + "last_updated": "2023-03-29T15:22:00.516Z", + "name": "et-{module}/0/16", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000016............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 109, + "fields": { + "created": "2023-03-29T15:22:00.518Z", + "last_updated": "2023-03-29T15:22:00.518Z", + "name": "et-{module}/0/17", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000017............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 110, + "fields": { + "created": "2023-03-29T15:22:00.521Z", + "last_updated": "2023-03-29T15:22:00.521Z", + "name": "et-{module}/0/18", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000018............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 111, + "fields": { + "created": "2023-03-29T15:22:00.523Z", + "last_updated": "2023-03-29T15:22:00.523Z", + "name": "et-{module}/0/19", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000019............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 112, + "fields": { + "created": "2023-03-29T15:22:00.526Z", + "last_updated": "2023-03-29T15:22:00.526Z", + "name": "et-{module}/0/20", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000020............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 113, + "fields": { + "created": "2023-03-29T15:22:00.529Z", + "last_updated": "2023-03-29T15:22:00.529Z", + "name": "et-{module}/0/21", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000021............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 114, + "fields": { + "created": "2023-03-29T15:22:00.531Z", + "last_updated": "2023-03-29T15:22:00.531Z", + "name": "et-{module}/0/22", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000022............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 115, + "fields": { + "created": "2023-03-29T15:22:00.533Z", + "last_updated": "2023-03-29T15:22:00.533Z", + "name": "et-{module}/0/23", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000023............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 116, + "fields": { + "created": "2023-03-29T15:22:00.536Z", + "last_updated": "2023-03-29T15:22:00.536Z", + "name": "et-{module}/0/24", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000024............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 117, + "fields": { + "created": "2023-03-29T15:22:00.539Z", + "last_updated": "2023-03-29T15:22:00.539Z", + "name": "et-{module}/0/25", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000025............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 118, + "fields": { + "created": "2023-03-29T15:22:00.541Z", + "last_updated": "2023-03-29T15:22:00.541Z", + "name": "et-{module}/0/26", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000026............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 119, + "fields": { + "created": "2023-03-29T15:22:00.544Z", + "last_updated": "2023-03-29T15:22:00.544Z", + "name": "et-{module}/0/27", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000027............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 120, + "fields": { + "created": "2023-03-29T15:22:00.547Z", + "last_updated": "2023-03-29T15:22:00.547Z", + "name": "et-{module}/0/28", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000028............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 121, + "fields": { + "created": "2023-03-29T15:22:00.549Z", + "last_updated": "2023-03-29T15:22:00.549Z", + "name": "et-{module}/0/29", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000029............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 122, + "fields": { + "created": "2023-03-29T15:22:00.552Z", + "last_updated": "2023-03-29T15:22:00.552Z", + "name": "et-{module}/0/30", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000030............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 123, + "fields": { + "created": "2023-03-29T15:22:00.554Z", + "last_updated": "2023-03-29T15:22:00.554Z", + "name": "et-{module}/0/31", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000031............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 124, + "fields": { + "created": "2023-03-29T15:22:00.557Z", + "last_updated": "2023-03-29T15:22:00.557Z", + "name": "et-{module}/0/32", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000032............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 125, + "fields": { + "created": "2023-03-29T15:22:00.559Z", + "last_updated": "2023-03-29T15:22:00.559Z", + "name": "et-{module}/0/33", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000033............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 126, + "fields": { + "created": "2023-03-29T15:22:00.562Z", + "last_updated": "2023-03-29T15:22:00.562Z", + "name": "et-{module}/0/34", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000034............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 127, + "fields": { + "created": "2023-03-29T15:22:00.564Z", + "last_updated": "2023-03-29T15:22:00.564Z", + "name": "et-{module}/0/35", + "label": "", + "description": "", + "device_type": null, + "module_type": 2, + "_name": "0000999999999999et-{module}/000035............", + "type": "40gbase-x-qsfpp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 148, + "fields": { + "created": "2023-07-18T14:34:41.540Z", + "last_updated": "2023-07-18T14:34:41.540Z", + "name": "GigabitEthernet0/0/0", + "label": "", + "description": "", + "device_type": 23, + "module_type": null, + "_name": "0000000099999999GigabitEthernet000000............", + "type": "1000base-t", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 149, + "fields": { + "created": "2023-07-18T14:34:41.547Z", + "last_updated": "2023-07-18T14:34:41.547Z", + "name": "GigabitEthernet0/0/1", + "label": "", + "description": "", + "device_type": 23, + "module_type": null, + "_name": "0000000099999999GigabitEthernet000001............", + "type": "1000base-t", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 150, + "fields": { + "created": "2023-07-18T14:34:41.552Z", + "last_updated": "2023-07-18T14:34:41.552Z", + "name": "GigabitEthernet0/0/2", + "label": "", + "description": "", + "device_type": 23, + "module_type": null, + "_name": "0000000099999999GigabitEthernet000002............", + "type": "1000base-x-sfp", + "mgmt_only": false + } + }, + { + "model": "dcim.interfacetemplate", + "pk": 151, + "fields": { + "created": "2023-07-18T14:34:41.558Z", + "last_updated": "2023-07-18T14:34:41.558Z", + "name": "GigabitEthernet0", + "label": "", + "description": "", + "device_type": 23, + "module_type": null, + "_name": "9999999999999999GigabitEthernet000000............", + "type": "1000base-t", + "mgmt_only": true + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 61, + "fields": { + "created": "2023-07-18T15:18:32.707Z", + "last_updated": "2023-07-18T15:18:32.707Z", + "name": "1", + "_name": "00000001", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 13, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 62, + "fields": { + "created": "2023-07-18T15:18:32.715Z", + "last_updated": "2023-07-18T15:18:32.715Z", + "name": "2", + "_name": "00000002", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 14, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 63, + "fields": { + "created": "2023-07-18T15:18:32.722Z", + "last_updated": "2023-07-18T15:18:32.722Z", + "name": "3", + "_name": "00000003", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 15, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 64, + "fields": { + "created": "2023-07-18T15:18:32.730Z", + "last_updated": "2023-07-18T15:18:32.730Z", + "name": "4", + "_name": "00000004", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 16, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 65, + "fields": { + "created": "2023-07-18T15:18:32.737Z", + "last_updated": "2023-07-18T15:18:32.737Z", + "name": "5", + "_name": "00000005", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 17, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 66, + "fields": { + "created": "2023-07-18T15:18:32.746Z", + "last_updated": "2023-07-18T15:18:32.746Z", + "name": "6", + "_name": "00000006", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 18, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 67, + "fields": { + "created": "2023-07-18T15:18:32.754Z", + "last_updated": "2023-07-18T15:18:32.754Z", + "name": "7", + "_name": "00000007", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 19, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 68, + "fields": { + "created": "2023-07-18T15:18:32.763Z", + "last_updated": "2023-07-18T15:18:32.763Z", + "name": "8", + "_name": "00000008", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 20, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 69, + "fields": { + "created": "2023-07-18T15:18:32.770Z", + "last_updated": "2023-07-18T15:18:32.770Z", + "name": "9", + "_name": "00000009", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 21, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 70, + "fields": { + "created": "2023-07-18T15:18:32.778Z", + "last_updated": "2023-07-18T15:18:32.778Z", + "name": "10", + "_name": "00000010", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 22, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 71, + "fields": { + "created": "2023-07-18T15:18:32.785Z", + "last_updated": "2023-07-18T15:18:32.785Z", + "name": "11", + "_name": "00000011", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 23, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 72, + "fields": { + "created": "2023-07-18T15:18:32.794Z", + "last_updated": "2023-07-18T15:18:32.794Z", + "name": "12", + "_name": "00000012", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 24, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 73, + "fields": { + "created": "2023-07-18T15:18:32.802Z", + "last_updated": "2023-07-18T15:18:32.802Z", + "name": "13", + "_name": "00000013", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 25, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 74, + "fields": { + "created": "2023-07-18T15:18:32.810Z", + "last_updated": "2023-07-18T15:18:32.810Z", + "name": "14", + "_name": "00000014", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 26, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 75, + "fields": { + "created": "2023-07-18T15:18:32.818Z", + "last_updated": "2023-07-18T15:18:32.818Z", + "name": "15", + "_name": "00000015", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 27, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 76, + "fields": { + "created": "2023-07-18T15:18:32.826Z", + "last_updated": "2023-07-18T15:18:32.826Z", + "name": "16", + "_name": "00000016", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 28, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 77, + "fields": { + "created": "2023-07-18T15:18:32.834Z", + "last_updated": "2023-07-18T15:18:32.834Z", + "name": "17", + "_name": "00000017", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 29, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 78, + "fields": { + "created": "2023-07-18T15:18:32.843Z", + "last_updated": "2023-07-18T15:18:32.843Z", + "name": "18", + "_name": "00000018", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 30, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 79, + "fields": { + "created": "2023-07-18T15:18:32.850Z", + "last_updated": "2023-07-18T15:18:32.850Z", + "name": "19", + "_name": "00000019", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 31, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 80, + "fields": { + "created": "2023-07-18T15:18:32.859Z", + "last_updated": "2023-07-18T15:18:32.859Z", + "name": "20", + "_name": "00000020", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 32, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 81, + "fields": { + "created": "2023-07-18T15:18:32.867Z", + "last_updated": "2023-07-18T15:18:32.867Z", + "name": "21", + "_name": "00000021", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 33, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 82, + "fields": { + "created": "2023-07-18T15:18:32.875Z", + "last_updated": "2023-07-18T15:18:32.875Z", + "name": "22", + "_name": "00000022", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 34, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 83, + "fields": { + "created": "2023-07-18T15:18:32.883Z", + "last_updated": "2023-07-18T15:18:32.883Z", + "name": "23", + "_name": "00000023", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 35, + "rear_port_position": 1 + } + }, + { + "model": "dcim.frontporttemplate", + "pk": 84, + "fields": { + "created": "2023-07-18T15:18:32.890Z", + "last_updated": "2023-07-18T15:18:32.890Z", + "name": "24", + "_name": "00000024", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "rear_port": 36, + "rear_port_position": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 13, + "fields": { + "created": "2023-07-18T15:18:32.570Z", + "last_updated": "2023-07-18T15:18:32.570Z", + "name": "1", + "_name": "00000001", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 14, + "fields": { + "created": "2023-07-18T15:18:32.577Z", + "last_updated": "2023-07-18T15:18:32.577Z", + "name": "2", + "_name": "00000002", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 15, + "fields": { + "created": "2023-07-18T15:18:32.582Z", + "last_updated": "2023-07-18T15:18:32.582Z", + "name": "3", + "_name": "00000003", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 16, + "fields": { + "created": "2023-07-18T15:18:32.587Z", + "last_updated": "2023-07-18T15:18:32.587Z", + "name": "4", + "_name": "00000004", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 17, + "fields": { + "created": "2023-07-18T15:18:32.593Z", + "last_updated": "2023-07-18T15:18:32.593Z", + "name": "5", + "_name": "00000005", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 18, + "fields": { + "created": "2023-07-18T15:18:32.598Z", + "last_updated": "2023-07-18T15:18:32.598Z", + "name": "6", + "_name": "00000006", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 19, + "fields": { + "created": "2023-07-18T15:18:32.603Z", + "last_updated": "2023-07-18T15:18:32.603Z", + "name": "7", + "_name": "00000007", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 20, + "fields": { + "created": "2023-07-18T15:18:32.609Z", + "last_updated": "2023-07-18T15:18:32.609Z", + "name": "8", + "_name": "00000008", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 21, + "fields": { + "created": "2023-07-18T15:18:32.615Z", + "last_updated": "2023-07-18T15:18:32.615Z", + "name": "9", + "_name": "00000009", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 22, + "fields": { + "created": "2023-07-18T15:18:32.620Z", + "last_updated": "2023-07-18T15:18:32.620Z", + "name": "10", + "_name": "00000010", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 23, + "fields": { + "created": "2023-07-18T15:18:32.625Z", + "last_updated": "2023-07-18T15:18:32.625Z", + "name": "11", + "_name": "00000011", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 24, + "fields": { + "created": "2023-07-18T15:18:32.631Z", + "last_updated": "2023-07-18T15:18:32.631Z", + "name": "12", + "_name": "00000012", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 25, + "fields": { + "created": "2023-07-18T15:18:32.636Z", + "last_updated": "2023-07-18T15:18:32.636Z", + "name": "13", + "_name": "00000013", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 26, + "fields": { + "created": "2023-07-18T15:18:32.641Z", + "last_updated": "2023-07-18T15:18:32.641Z", + "name": "14", + "_name": "00000014", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 27, + "fields": { + "created": "2023-07-18T15:18:32.648Z", + "last_updated": "2023-07-18T15:18:32.648Z", + "name": "15", + "_name": "00000015", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 28, + "fields": { + "created": "2023-07-18T15:18:32.653Z", + "last_updated": "2023-07-18T15:18:32.653Z", + "name": "16", + "_name": "00000016", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 29, + "fields": { + "created": "2023-07-18T15:18:32.659Z", + "last_updated": "2023-07-18T15:18:32.659Z", + "name": "17", + "_name": "00000017", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 30, + "fields": { + "created": "2023-07-18T15:18:32.665Z", + "last_updated": "2023-07-18T15:18:32.665Z", + "name": "18", + "_name": "00000018", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 31, + "fields": { + "created": "2023-07-18T15:18:32.670Z", + "last_updated": "2023-07-18T15:18:32.670Z", + "name": "19", + "_name": "00000019", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 32, + "fields": { + "created": "2023-07-18T15:18:32.675Z", + "last_updated": "2023-07-18T15:18:32.675Z", + "name": "20", + "_name": "00000020", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 33, + "fields": { + "created": "2023-07-18T15:18:32.681Z", + "last_updated": "2023-07-18T15:18:32.681Z", + "name": "21", + "_name": "00000021", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 34, + "fields": { + "created": "2023-07-18T15:18:32.687Z", + "last_updated": "2023-07-18T15:18:32.687Z", + "name": "22", + "_name": "00000022", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 35, + "fields": { + "created": "2023-07-18T15:18:32.692Z", + "last_updated": "2023-07-18T15:18:32.692Z", + "name": "23", + "_name": "00000023", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.rearporttemplate", + "pk": 36, + "fields": { + "created": "2023-07-18T15:18:32.698Z", + "last_updated": "2023-07-18T15:18:32.698Z", + "name": "24", + "_name": "00000024", + "label": "", + "description": "", + "device_type": 24, + "module_type": null, + "type": "lc", + "color": "", + "positions": 1 + } + }, + { + "model": "dcim.consoleporttemplate", + "pk": 1, + "fields": { + "created": "2023-03-21T10:38:05.860Z", + "last_updated": "2023-03-21T10:38:05.860Z", + "name": "con0", + "_name": "con00000000", + "label": "", + "description": "", + "device_type": 15, + "module_type": null, + "type": "rj-45" + } + }, + { + "model": "dcim.consoleporttemplate", + "pk": 2, + "fields": { + "created": "2023-03-29T15:19:44.701Z", + "last_updated": "2023-03-29T15:19:44.701Z", + "name": "re0.console", + "_name": "re00000000.console", + "label": "", + "description": "", + "device_type": 16, + "module_type": null, + "type": "rj-45" + } + }, + { + "model": "dcim.consoleporttemplate", + "pk": 3, + "fields": { + "created": "2023-03-29T15:19:44.705Z", + "last_updated": "2023-03-29T15:19:44.705Z", + "name": "re1.console", + "_name": "re00000001.console", + "label": "", + "description": "", + "device_type": 16, + "module_type": null, + "type": "rj-45" + } + }, + { + "model": "dcim.consoleporttemplate", + "pk": 9, + "fields": { + "created": "2023-07-18T14:34:41.524Z", + "last_updated": "2023-07-18T14:34:41.524Z", + "name": "console", + "_name": "console", + "label": "", + "description": "", + "device_type": 23, + "module_type": null, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 145, + "fields": { + "created": "2023-07-18T14:35:28.659Z", + "last_updated": "2023-07-18T14:35:28.659Z", + "name": "{module}/0", + "_name": "{module}/00000000", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 146, + "fields": { + "created": "2023-07-18T14:35:28.664Z", + "last_updated": "2023-07-18T14:35:28.664Z", + "name": "{module}/1", + "_name": "{module}/00000001", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 147, + "fields": { + "created": "2023-07-18T14:35:28.669Z", + "last_updated": "2023-07-18T14:35:28.669Z", + "name": "{module}/2", + "_name": "{module}/00000002", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 148, + "fields": { + "created": "2023-07-18T14:35:28.674Z", + "last_updated": "2023-07-18T14:35:28.674Z", + "name": "{module}/3", + "_name": "{module}/00000003", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 149, + "fields": { + "created": "2023-07-18T14:35:28.679Z", + "last_updated": "2023-07-18T14:35:28.679Z", + "name": "{module}/4", + "_name": "{module}/00000004", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 150, + "fields": { + "created": "2023-07-18T14:35:28.684Z", + "last_updated": "2023-07-18T14:35:28.684Z", + "name": "{module}/5", + "_name": "{module}/00000005", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 151, + "fields": { + "created": "2023-07-18T14:35:28.689Z", + "last_updated": "2023-07-18T14:35:28.689Z", + "name": "{module}/6", + "_name": "{module}/00000006", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 152, + "fields": { + "created": "2023-07-18T14:35:28.694Z", + "last_updated": "2023-07-18T14:35:28.694Z", + "name": "{module}/7", + "_name": "{module}/00000007", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 153, + "fields": { + "created": "2023-07-18T14:35:28.698Z", + "last_updated": "2023-07-18T14:35:28.698Z", + "name": "{module}/8", + "_name": "{module}/00000008", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 154, + "fields": { + "created": "2023-07-18T14:35:28.703Z", + "last_updated": "2023-07-18T14:35:28.703Z", + "name": "{module}/9", + "_name": "{module}/00000009", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 155, + "fields": { + "created": "2023-07-18T14:35:28.708Z", + "last_updated": "2023-07-18T14:35:28.708Z", + "name": "{module}/10", + "_name": "{module}/00000010", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 156, + "fields": { + "created": "2023-07-18T14:35:28.713Z", + "last_updated": "2023-07-18T14:35:28.713Z", + "name": "{module}/11", + "_name": "{module}/00000011", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 157, + "fields": { + "created": "2023-07-18T14:35:28.721Z", + "last_updated": "2023-07-18T14:35:28.721Z", + "name": "{module}/12", + "_name": "{module}/00000012", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 158, + "fields": { + "created": "2023-07-18T14:35:28.825Z", + "last_updated": "2023-07-18T14:35:28.825Z", + "name": "{module}/13", + "_name": "{module}/00000013", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 159, + "fields": { + "created": "2023-07-18T14:35:28.831Z", + "last_updated": "2023-07-18T14:35:28.831Z", + "name": "{module}/14", + "_name": "{module}/00000014", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 160, + "fields": { + "created": "2023-07-18T14:35:28.837Z", + "last_updated": "2023-07-18T14:35:28.837Z", + "name": "{module}/15", + "_name": "{module}/00000015", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 161, + "fields": { + "created": "2023-07-18T14:35:28.843Z", + "last_updated": "2023-07-18T14:35:28.843Z", + "name": "{module}/16", + "_name": "{module}/00000016", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 162, + "fields": { + "created": "2023-07-18T14:35:28.848Z", + "last_updated": "2023-07-18T14:35:28.848Z", + "name": "{module}/17", + "_name": "{module}/00000017", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 163, + "fields": { + "created": "2023-07-18T14:35:28.853Z", + "last_updated": "2023-07-18T14:35:28.853Z", + "name": "{module}/18", + "_name": "{module}/00000018", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 164, + "fields": { + "created": "2023-07-18T14:35:28.858Z", + "last_updated": "2023-07-18T14:35:28.858Z", + "name": "{module}/19", + "_name": "{module}/00000019", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 165, + "fields": { + "created": "2023-07-18T14:35:28.863Z", + "last_updated": "2023-07-18T14:35:28.863Z", + "name": "{module}/20", + "_name": "{module}/00000020", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 166, + "fields": { + "created": "2023-07-18T14:35:28.872Z", + "last_updated": "2023-07-18T14:35:28.872Z", + "name": "{module}/21", + "_name": "{module}/00000021", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 167, + "fields": { + "created": "2023-07-18T14:35:28.878Z", + "last_updated": "2023-07-18T14:35:28.878Z", + "name": "{module}/22", + "_name": "{module}/00000022", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.consoleserverporttemplate", + "pk": 168, + "fields": { + "created": "2023-07-18T14:35:28.883Z", + "last_updated": "2023-07-18T14:35:28.883Z", + "name": "{module}/23", + "_name": "{module}/00000023", + "label": "", + "description": "", + "device_type": null, + "module_type": 10, + "type": "" + } + }, + { + "model": "dcim.devicerole", + "pk": 5, + "fields": { + "created": "2023-03-21T10:31:36.540Z", + "last_updated": "2023-10-31T11:36:18.573Z", + "custom_field_data": {}, + "name": "Provider Edge Router (PE)", + "slug": "pe", + "color": "9c27b0", + "vm_role": false, + "description": "" + } + }, + { + "model": "dcim.devicerole", + "pk": 6, + "fields": { + "created": "2023-03-21T10:31:53.846Z", + "last_updated": "2023-10-31T10:46:02.160Z", + "custom_field_data": {}, + "name": "Peer", + "slug": "peer", + "color": "9e9e9e", + "vm_role": false, + "description": "" + } + }, + { + "model": "dcim.devicerole", + "pk": 7, + "fields": { + "created": "2023-03-29T15:22:40.233Z", + "last_updated": "2023-10-31T11:36:33.972Z", + "custom_field_data": {}, + "name": "Edge Router", + "slug": "edge-router", + "color": "673ab7", + "vm_role": false, + "description": "" + } + }, + { + "model": "dcim.devicerole", + "pk": 9, + "fields": { + "created": "2023-07-18T14:30:51.509Z", + "last_updated": "2023-07-18T14:30:51.509Z", + "custom_field_data": {}, + "name": "Console Server", + "slug": "console-server", + "color": "ff9800", + "vm_role": false, + "description": "" + } + }, + { + "model": "dcim.devicerole", + "pk": 10, + "fields": { + "created": "2023-07-18T15:16:30.108Z", + "last_updated": "2023-07-18T15:16:30.108Z", + "custom_field_data": {}, + "name": "Patch Panel", + "slug": "patch-panel", + "color": "9e9e9e", + "vm_role": false, + "description": "" + } + }, + { + "model": "dcim.platform", + "pk": 2, + "fields": { + "created": "2023-03-29T15:26:01.327Z", + "last_updated": "2023-03-29T15:26:01.327Z", + "custom_field_data": {}, + "name": "Junos", + "slug": "junos", + "manufacturer": 6, + "napalm_driver": "", + "napalm_args": null, + "description": "" + } + }, + { + "model": "dcim.platform", + "pk": 3, + "fields": { + "created": "2023-10-31T10:46:24.573Z", + "last_updated": "2023-10-31T10:47:14.383Z", + "custom_field_data": {}, + "name": "EOS", + "slug": "eos", + "manufacturer": 12, + "napalm_driver": "", + "napalm_args": null, + "description": "" + } + }, + { + "model": "dcim.platform", + "pk": 4, + "fields": { + "created": "2023-10-31T10:46:56.491Z", + "last_updated": "2023-10-31T10:46:56.491Z", + "custom_field_data": {}, + "name": "Junos EVO", + "slug": "junos-evo", + "manufacturer": 6, + "napalm_driver": "", + "napalm_args": null, + "description": "" + } + }, + { + "model": "ipam.role", + "pk": 1, + "fields": { + "created": "2019-11-05T00:00:00Z", + "last_updated": "2019-11-05T00:12:36.393Z", + "custom_field_data": {}, + "name": "Site local", + "slug": "site-local", + "weight": 1000, + "description": "" + } + }, + { + "model": "ipam.role", + "pk": 4, + "fields": { + "created": "2023-07-06T10:23:31.505Z", + "last_updated": "2023-07-06T10:23:31.505Z", + "custom_field_data": {}, + "name": "Transfer Network", + "slug": "transfer-network", + "weight": 1000, + "description": "" + } + }, + { + "model": "ipam.role", + "pk": 5, + "fields": { + "created": "2023-09-26T13:31:56.902Z", + "last_updated": "2023-10-31T10:51:04.187Z", + "custom_field_data": {}, + "name": "Loopback IPs", + "slug": "loopback-ips", + "weight": 1000, + "description": "" + } + } +] diff --git a/scripts/helper_library/pyproject.toml b/scripts/helper_library/pyproject.toml new file mode 100644 index 0000000..da02680 --- /dev/null +++ b/scripts/helper_library/pyproject.toml @@ -0,0 +1,38 @@ +[tool.ruff] +select = ["ANN", "C90", "D", "E", "F", "I", "W", "S"] +ignore = ["ANN101", "D203", "D213", "D211", "D401", "D404"] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["A", "ARG", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".venv", + "netbox", + + # Tests + "test_basescript.py", + "test_utils.py", + "test_validators.py", + "testutils.py", +] +per-file-ignores = {"configuration.py" = ["E501"]} + +# Same as Black. +line-length = 120 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +target-version = "py39" + +[tool.ruff.mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 10 + +[tool.black] +exclude = "netbox/" + +[tool.pytest.ini_options] +addopts = "--ignore netbox/" diff --git a/scripts/helper_library/tests/test_basescript.py b/scripts/helper_library/tests/test_basescript.py new file mode 100644 index 0000000..d970011 --- /dev/null +++ b/scripts/helper_library/tests/test_basescript.py @@ -0,0 +1,242 @@ +#!/usr/bin/python3 + + +from copy import deepcopy +from django.test import TestCase +import json + +import scripts.common.utils as utils +from scripts.common.basescript import AbortScript, CommonAPIScript +from scripts.common.errors import InvalidInput, NetBoxDataError + + +def setUpModule() -> None: + pass + + +class TestScriptBasic(CommonAPIScript): + def run_method(self, data, commit): + req_data = self.get_request_data(data) + + # successful returns + if req_data.get("return string"): + utils.log_maybe(self, "info", "Fourty") + utils.log_maybe(self, "success", "Two") + return "Fourtytwo" + if req_data.get("return dict"): + return { + "magic key": 42, + } + + # errors + if req_data.get("InvalidInput"): + raise InvalidInput("InvalidInput error") + if req_data.get("NetBoxDataError"): + raise NetBoxDataError("NetBoxDataError error") + if req_data.get("Exception"): + raise Exception("Oh noes!") + + +class TestScriptWithParamValidation(CommonAPIScript): + def run_method(self, data, commit): + req_data = self.get_request_data(data) + + self.validate_parameters(req_data, getattr(self, "validator_map", {})) + + +RET_DICT_TEMPLATE = { + "success": False, + "logs": [], + "ret": None, + "errmsg": None, +} + + +class ScriptTestCase(TestCase): + @classmethod + def setUpTestData(cls): + """Run once per TestCase instance.""" + pass + + # Test get_request_data(), logging, error handling, and returns + def test_script_missing_request_param(self): + script = TestScriptBasic() + + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite missing 'request' parameter!", + ): + script.run({}, False) + + def test_script_string_output(self): + script = TestScriptBasic() + + req = '{"return string": true}' + expected = { + "success": True, + "logs": [ + {"sev": "info", "msg": "Fourty"}, + {"sev": "success", "msg": "Two"}, + ], + "ret": "Fourtytwo", + "errmsg": None, + } + + res = script.run({"request": req}, False) + self.assertEqual(json.dumps(expected), res) + + def test_script_dict_output(self): + script = TestScriptBasic() + + req = {"return dict": True} + expected = deepcopy(RET_DICT_TEMPLATE) + expected.update( + { + "success": True, + "ret": { + "magic key": 42, + }, + } + ) + res = script.run({"request": req}, False) + self.assertEqual(json.dumps(expected), res) + + def test_script_InvalidInput_error(self): + script = TestScriptBasic() + + req = '{"InvalidInput": true}' + expected = deepcopy(RET_DICT_TEMPLATE) + expected.update( + { + "success": False, + "errmsg": "InvalidInput error", + } + ) + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite InvalidInput!", + ): + script.run({"request": req}, False) + self.assertEqual(json.dumps(expected), script.output) + + def test_script_NetBoxDataError_error(self): + script = TestScriptBasic() + + req = '{"NetBoxDataError": true}' + expected = deepcopy(RET_DICT_TEMPLATE) + expected.update( + { + "success": False, + "errmsg": "NetBoxDataError error", + } + ) + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite NetBoxDataError!", + ): + script.run({"request": req}, False) + self.assertEqual(json.dumps(expected), script.output) + + def test_script_uncaught_Exception(self): + script = TestScriptBasic() + + req = '{"Exception": true}' + expected = deepcopy(RET_DICT_TEMPLATE) + expected.update( + { + "success": False, + "errmsg": "An unexpected error occured: Oh noes!", + } + ) + with self.assertRaises( + Exception, + msg=f"Script not aborted despite Exception!", + ): + script.run({"request": req}, False) + self.assertEqual(json.dumps(expected), script.output) + + def test_script_invalid_JSON(self): + script = TestScriptBasic() + + req = "{ JSON is quite picky, right?" + expected = deepcopy(RET_DICT_TEMPLATE) + expected.update( + { + "success": False, + "errmsg": "Failed to unmarshal request JSON: Expecting property name enclosed in double quotes: line 1 column 3 (char 2)", + } + ) + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite invalid JSON input!", + ): + script.run({"request": req}, False) + self.assertEqual(json.dumps(expected), script.output) + + # Test validate_parameters() + + def test_validate_parameters_invalid_JSON(self): + script = TestScriptWithParamValidation() + + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite 'request' param being a string!", + ): + # Invalid params, should be dict, is string + script.run({"request": "42"}, False) + + def test_validate_parameters_missing_param(self): + script = TestScriptWithParamValidation() + + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite 'request' param being a string!", + ): + script.validator_map = { + "non_existing_param": None, + } + script.run({"request": {}}, False) + + def test_validate_parameters_existence_check(self): + script = TestScriptWithParamValidation() + + req = { + "vid": 42, + } + script.validator_map = { + "vid": None, + } + script.run({"request": req}, False) + + def test_validate_parameters_unknown_validator(self): + script = TestScriptWithParamValidation() + + req = { + "ip": "192.0.2.23", + "pfx": "192.0.2.42/24", + "vid": 42, + } + + with self.assertRaises( + AbortScript, + msg=f"Script not aborted despite unknown validator referenced!", + ): + script.validator_map = {"vid": "non-existing-validator"} + script.run({"request": req}, False) + + def test_validate_parameters_valid_validators(self): + script = TestScriptWithParamValidation() + + req = { + "ip": "192.0.2.23", + "pfx": "192.0.2.42/24", + "vid": 42, + } + + script.validator_map = { + "ip": "ip", + "pfx": "prefix", + "vid": "vlan_id", + # skip testing device to not add requirement on DB fixtures + } + script.run({"request": req}, False) diff --git a/scripts/helper_library/tests/test_utils.py b/scripts/helper_library/tests/test_utils.py new file mode 100644 index 0000000..b6ea395 --- /dev/null +++ b/scripts/helper_library/tests/test_utils.py @@ -0,0 +1,1194 @@ +#!/usr/bin/python3 +# +# Maximilian Wilhelm +# -- Thu 27 Jul 2023 05:38:01 PM CEST +# + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +import netaddr +import uuid + +from circuits.choices import CircuitStatusChoices +from circuits.models import Circuit, CircuitType, Provider +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import ( + ConsolePort, + ConsoleServerPort, + Device, + Module, + ModuleBay, + ModuleType, + FrontPort, + RearPort, + Platform, + Site, +) +from dcim.models.device_components import Interface +from extras.choices import CustomFieldTypeChoices +from extras.models import CustomField, Tag +from extras.scripts import Script +from ipam.choices import PrefixStatusChoices +from ipam.models import VRF, IPAddress, Prefix, Role +from netbox.settings import VERSION +from tenancy.models import Tenant +from utilities.choices import ColorChoices +from scripts.common.constants import ( + CIRCUIT_TYPE_SLUG_DARK_FIBER, + DEVICE_ROLE_SLUG_CS, + DEVICE_ROLE_SLUG_EDGE_ROUTER, + PLATFORM_SLUG_EOS, + PLATFORM_SLUG_JUNOS, + PREFIX_ROLE_SLUG_LOOPBACK_IPS, + PREFIX_ROLE_SLUG_SITE_LOCAL, + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, +) +from scripts.common.errors import InvalidInput, NetBoxDataError +import scripts.common.utils as utils +import scripts.tests.testutils as testutils + +SINGLE_CABLE_ENDPOINT = [int(n) for n in VERSION.split("-")[0].split(".")] < [3, 3, 0] +DEVICE_ROLE_SLUG_PE = "pe" + + +def setup_customfields() -> None: + # "pop" custom field on Prefixes + # This uses an integer value to simulate a relationship to the PK of a NetBox plugin + # holding information about all the POPs we have in our network. + pfx_pop = CustomField( + name="pop", + type=CustomFieldTypeChoices.TYPE_INTEGER, + ) + pfx_pop.save() + pfx_pop.content_types.set([ContentType.objects.get_for_model(Prefix)]) + + # "gateway_ip" custom field on IPAddresses + gateway_ip = CustomField( + name="gateway_ip", + type=CustomFieldTypeChoices.TYPE_TEXT, + ) + gateway_ip.save() + gateway_ip.content_types.set([ContentType.objects.get_for_model(IPAddress)]) + + +def setup_topology() -> None: + """Set up test topology. + + Site: DC02 + + Create a PE pe-test in Site DC02 using a Mellanox SN2010 device, + iface Ethernet19 is put into lag1. + + Create patch panel pp-pe, where PE ifaces Ethernet1 is connected + to front ports 1 of the panel. + + Site DC01 + + Create edge-test in Site DC01 using Junper QFX10008 model and adds a + QFX10000-30C Module into FPC0 slot. + + Create pp-DC01 with no front/rear ports connected. + """ + + # Set up pe-test + pp-pe + cabling + pe = testutils.create_device("pe-test", "SN2010", DEVICE_ROLE_SLUG_PE, "DC02") + pe_eth19 = utils.get_interface(pe, "Ethernet19") + utils.get_or_create_LAG_interface_with_members(pe, "ae0", [pe_eth19]) + + pp_cr = testutils.create_device("pp-pe", "24-port LC/LC PP", "patch-panel", "DC02") + pp_cr.save() + utils.connect_ports( + Interface.objects.get(device=pe, name="Ethernet1"), + FrontPort.objects.get(device=pp_cr, name="1"), + ) + + # Set up edge-test + fpc0 module + edge = testutils.create_device( + "edge-test", + "QFX10008", + DEVICE_ROLE_SLUG_EDGE_ROUTER, + "DC01", + PLATFORM_SLUG_JUNOS, + ) + fpc0 = Module( + device=edge, + module_bay=ModuleBay.objects.get(device=edge, name="FPC0"), + module_type=ModuleType.objects.get(model="QFX10000-30C"), + ) + fpc0.save() + edge_et_000 = utils.get_interface(edge, "et-0/0/0") + utils.get_or_create_LAG_interface_with_members(edge, "ae0", [edge_et_000]) + + # Set up connection between edge and PE + utils.connect_ports(pe_eth19, edge_et_000) + + peer = testutils.create_device("Peer", "NET-META-Peer", "peer", "NET-META-ANY") + peer.save() + + +def setup_ipam() -> None: + """Set up IPAM data base line""" + test_net_1 = Prefix( + prefix="192.2.0.0/24", + description="TEST-NET-1", + status=PrefixStatusChoices.STATUS_CONTAINER, + is_pool=False, + role=Role.objects.get(slug=PREFIX_ROLE_SLUG_SITE_LOCAL), + custom_field_data={"pop": 2342}, + ) + test_net_1.save() + pop_tag = Tag(name="NET:PFX:PE_LOOPBACKS:test-a") + pop_tag.save() + test_net_1.tags.add(pop_tag) + test_net_1.save() + + test_net_2 = Prefix( + prefix="198.51.100.0/24", + description="TEST-NET-2", + status=PrefixStatusChoices.STATUS_CONTAINER, + is_pool=False, + role=Role.objects.get(slug=PREFIX_ROLE_SLUG_SITE_LOCAL), + ) + test_net_2.save() + + ipv6_pfx = Prefix( + prefix="2001:db8:2342::/48", + description="IPv6 Site-local", + status=PrefixStatusChoices.STATUS_CONTAINER, + is_pool=False, + role=Role.objects.get(slug=PREFIX_ROLE_SLUG_SITE_LOCAL), + custom_field_data={"pop": 2342}, + ) + ipv6_pfx.save() + + pe_lo = Prefix( + prefix="2001:db8:2342:ffff::/64", + description="PE Loopback IPs - pad01", + status=PrefixStatusChoices.STATUS_CONTAINER, + role=Role.objects.get(slug=PREFIX_ROLE_SLUG_LOOPBACK_IPS), + is_pool=True, + custom_field_data={"pop": 2342}, + ) + pe_lo.save() + + pe_lo_non_pool = Prefix( + prefix="192.168.0.0/31", + description="PE Loopback IPs - v4 tiny", + status=PrefixStatusChoices.STATUS_CONTAINER, + role=Role.objects.get(slug=PREFIX_ROLE_SLUG_LOOPBACK_IPS), + is_pool=False, + custom_field_data={"pop": 2342}, + ) + pe_lo_non_pool.save() + + xfer = Prefix( + prefix="100.64.0.0/24", + status=PrefixStatusChoices.STATUS_CONTAINER, + role=Role.objects.get(slug=PREFIX_ROLE_SLUG_TRANSFER_NETWORK), + is_pool=False, + ) + xfer.save() + + +class ScriptTestCase(TestCase): + fixtures = [ + "/opt/fixtures/templates.json", + ] + + @classmethod + def setUpTestData(cls): + """Run once per TestCase instance.""" + # Create console server (to have ConsoleServerPort(s)) + cs = testutils.create_device("cs-test", "ISR4331", DEVICE_ROLE_SLUG_CS, "DC01") + nim = Module( + device=cs, + module_bay=ModuleBay.objects.get(device=cs, name="0/1"), + module_type=ModuleType.objects.get(model="NIM-24A"), + ) + nim.save() + + vrf = VRF( + id=1, + name="VRF1", + ) + vrf.save() + + setup_customfields() + setup_topology() + setup_ipam() + + ################################################################################ + # Generic wrappers # + ################################################################################ + + def test_get_port_type(self): + self.assertEqual( + utils._get_port_type(ConsolePort.objects.all()[0]), "Console Port" + ) + self.assertEqual( + utils._get_port_type(ConsoleServerPort.objects.all()[0]), + "Console Server Port", + ) + self.assertEqual(utils._get_port_type(Interface.objects.all()[0]), "Interface") + self.assertEqual(utils._get_port_type(FrontPort.objects.all()[0]), "Front Port") + self.assertEqual(utils._get_port_type(RearPort.objects.all()[0]), "Rear Port") + self.assertEqual(utils._get_port_type("foo"), "unknown") + + ################################################################################ + # Circuit related helper functions # + ################################################################################ + + def test_terminate_circuit_at_site(self): + c = Circuit( + cid=uuid.uuid4(), + provider=Provider.objects.get(name="Provider1"), + type=CircuitType.objects.get(slug=CIRCUIT_TYPE_SLUG_DARK_FIBER), + status=CircuitStatusChoices.STATUS_ACTIVE, + ) + c.save() + + # Circuit ends are unterminated by default + self.assertIsNone( + c.termination_a, + "A-End termination of newly created circuit should be None!", + ) + self.assertIsNone( + c.termination_z, + "Z-End termination of newly created circuit should be None!", + ) + + site_net_meta_any = Site.objects.get(name="NET-META-ANY") + utils.terminate_circuit_at_site(c, site_net_meta_any, True) + self.assertEqual( + site_net_meta_any, + c.termination_a.site, + "A-End termination not at NET-META-ANY!", + ) + + site_lax_dc01 = Site.objects.get(name="DC01") + utils.terminate_circuit_at_site(c, site_lax_dc01, False) + self.assertEqual( + site_lax_dc01, + c.termination_z.site, + "Z-End termination not at DC01!", + ) + + def test_connect_circuit_termination_to_port(self): + circuit = Circuit( + cid=uuid.uuid4(), + provider=Provider.objects.get(name="Provider1"), + type=CircuitType.objects.get(slug=CIRCUIT_TYPE_SLUG_DARK_FIBER), + status=CircuitStatusChoices.STATUS_ACTIVE, + ) + circuit.save() + + # Terminate both ends of the Circuit + site_net_meta_any = Site.objects.get(name="NET-META-ANY") + ct_a = utils.terminate_circuit_at_site(circuit, site_net_meta_any, True) + + site_lax_dc02 = Site.objects.get(name="DC02") + ct_b = utils.terminate_circuit_at_site(circuit, site_lax_dc02, False) + + peer = Device.objects.get(name="Peer") + peer_iface = Interface( + device=peer, name="test1", type=InterfaceTypeChoices.TYPE_100GE_QSFP28 + ) + peer_iface.save() + if SINGLE_CABLE_ENDPOINT: + self.assertIsNone( + peer_iface.connected_endpoint, + msg="Expected newly created Peer interface to be unconnected.", + ) + else: + self.assertIsNone( + peer_iface.connected_endpoints[0], + msg="Expected newly created Peer interface to be unconnected.", + ) + + # Connect A-End + cable_a = utils.connect_circuit_termination_to_port(ct_a, peer_iface, False) + if SINGLE_CABLE_ENDPOINT: + self.assertEqual( + cable_a.termination_a, + ct_a, + msg="Expected cable A-End to be connected to ct_a", + ) + self.assertEqual( + cable_a.termination_b, + peer_iface, + msg="Expected cable B-End to be connected to peer iface", + ) + else: + self.assertEqual( + cable_a.a_terminations[0], + ct_a, + msg="Expected cable A-End to be connected to ct_a", + ) + self.assertEqual( + cable_a.b_terminations[0], + peer_iface, + msg="Expected cable B-End to be connected to peer iface", + ) + self.assertEqual( + cable_a.status, + LinkStatusChoices.STATUS_CONNECTED, + msg="Cable should be connected, but isn't", + ) + + # Try to connect B-End to already connected port + try: + utils.connect_circuit_termination_to_port(ct_b, peer_iface, True) + self.fail( + "Connecting B-End of circuit to already connected port should fail" + ) + except InvalidInput as i: + pass + + # Try to re-connect A-End + try: + peer_iface2 = Interface( + device=peer, name="test2", type=InterfaceTypeChoices.TYPE_100GE_QSFP28 + ) + peer_iface2.save() + utils.connect_circuit_termination_to_port(ct_a, peer_iface2, True) + self.fail("Connecting already connected A-End of circuit should fail.") + except InvalidInput as i: + pass + + # Connect B-End to panel rear port + pp = Device.objects.get(name="pp-pe") + pp_rp = RearPort.objects.get( + device=pp, + name="24", + ) + cable_b = utils.connect_circuit_termination_to_port(ct_b, pp_rp, True) + if SINGLE_CABLE_ENDPOINT: + self.assertEqual( + cable_b.termination_a, + ct_b, + msg="Expected cable A-End to be connected to ct_b", + ) + self.assertEqual( + cable_b.termination_b, + pp_rp, + msg="Expected cable B-End to be connected to PP Rear Port", + ) + else: + self.assertEqual( + cable_b.a_terminations[0], + ct_b, + msg="Expected cable A-End to be connected to ct_b", + ) + self.assertEqual( + cable_b.b_termination[0], + pp_rp, + msg="Expected cable B-End to be connected to PP Rear Port", + ) + self.assertEqual( + cable_b.status, + LinkStatusChoices.STATUS_PLANNED, + msg="Cable should be planned, but isn't", + ) + + def test_connect_ports(self): + pe = Device.objects.get(name="pe-test") + pp = Device.objects.get(name="pp-pe") + + pe_eth5 = Interface.objects.get(device=pe, name="Ethernet5") + pp_fp5 = FrontPort.objects.get(device=pp, name="5") + + self.assertIsNone(pe_eth5._link_peer, msg="PE Interface already connected") + self.assertIsNone(pp_fp5._link_peer, msg="Panel Front Port already connected") + + cable = utils.connect_ports(pe_eth5, pp_fp5, True) + + if SINGLE_CABLE_ENDPOINT: + self.assertEqual(cable.termination_a, pe_eth5) + self.assertEqual(cable.termination_b, pp_fp5) + else: + self.assertEqual(cable.a_terminations[0], pe_eth5) + self.assertEqual(cable.b_terminations[0], pp_fp5) + + self.assertEqual(cable.status, LinkStatusChoices.STATUS_PLANNED) + if SINGLE_CABLE_ENDPOINT: + self.assertEqual(pe_eth5._link_peer, pp_fp5) + self.assertEqual(pp_fp5._link_peer, pe_eth5) + else: + self.assertEqual(pe_eth5.link_peers[0], pp_fp5) + self.assertEqual(pp_fp5.link_peers[0], pe_eth5) + + # Re-creation of the same cable should be no-op, yielding the existing cable + cable2 = utils.connect_ports(pe_eth5, pp_fp5, True) + self.assertEqual(cable, cable2) + + # Try to connect the already connected PE interface (port_a) + pp_fp6 = FrontPort.objects.get(device=pp, name="6") + try: + cable = utils.connect_ports(pe_eth5, pp_fp6, True) + self.fail( + "Sholdn't be able to create a cable to an already connected port_a" + ) + except InvalidInput: + pass + + # Try to connect the alreay connected panel Front Port (port_b) + pe_eth6 = Interface.objects.get(device=pe, name="Ethernet6") + try: + cable = utils.connect_ports(pe_eth6, pp_fp5, True) + self.fail( + "Sholdn't be able to create a cable to an already connected port_b" + ) + except InvalidInput: + pass + + def test_remove_existing_cable_if_exists(self): + pe = Device.objects.get(name="pe-test") + pp = Device.objects.get(name="pp-pe") + + pe_eth5 = Interface.objects.get(device=pe, name="Ethernet5") + pp_fp5 = FrontPort.objects.get(device=pp, name="5") + + if SINGLE_CABLE_ENDPOINT: + self.assertIsNone(pe_eth5._link_peer, msg="PE Interface already connected") + self.assertIsNone( + pp_fp5._link_peer, msg="Panel Front Port already connected" + ) + else: + self.assertIsNone( + pe_eth5.link_peers[0], msg="PE Interface already connected" + ) + self.assertIsNone( + pp_fp5.link_peers[0], msg="Panel Front Port already connected" + ) + + iface = utils.remove_existing_cable_if_exists(pe_eth5) + self.assertEqual(pe_eth5, iface) + + utils.connect_ports(pe_eth5, pp_fp5, True) + if SINGLE_CABLE_ENDPOINT: + self.assertEqual(pe_eth5._link_peer, pp_fp5) + else: + self.assertEqual(pe_eth5.link_peers[0], pp_fp5) + + iface = utils.remove_existing_cable_if_exists(pe_eth5) + if SINGLE_CABLE_ENDPOINT: + self.assertEqual(iface._link_peer, None) + else: + self.assertEqual(iface.link_peers[0], None) + self.assertEqual(pe_eth5, iface) + + def test_get_other_cable_end_string(self): + pe = Device.objects.get(name="pe-test") + pp = Device.objects.get(name="pp-pe") + + pe_eth5 = Interface.objects.get(device=pe, name="Ethernet5") + pp_fp5 = FrontPort.objects.get(device=pp, name="5") + + cable = utils.connect_ports(pe_eth5, pp_fp5, True) + + pp_fp5_str_actual = utils.get_other_cable_end_string(cable, pe_eth5) + pp_fp5_str_expected = utils.get_port_string(pp_fp5) + self.assertEqual(pp_fp5_str_expected, pp_fp5_str_actual) + + pe_eth5_str_actual = utils.get_other_cable_end_string(cable, pp_fp5) + pe_eth5_str_expected = utils.get_port_string(pe_eth5) + self.assertEqual(pe_eth5_str_expected, pe_eth5_str_actual) + + pp_fp6 = FrontPort.objects.get(device=pp, name="6") + try: + utils.get_other_cable_end_string(cable, pp_fp6) + self.fail("get_other_cable_end_string with invalid port should fail") + except NetBoxDataError: + pass + + ################################################################################ + # Device related helper functions # + ################################################################################ + + def test_get_device(self): + self.assertIsNotNone(utils.get_device("pe-test")) + self.assertIsNone(utils.get_device("does-not-exist")) + + def test_get_device_platform_slug(self): + pe = Device.objects.get(name="pe-test") + edge = Device.objects.get(name="edge-test") + + self.assertEqual(PLATFORM_SLUG_JUNOS, utils.get_device_platform_slug(edge)) + self.assertIsNone(utils.get_device_platform_slug(pe)) + + def test_set_device_platform_slug(self): + pe = Device.objects.get(name="pe-test") + edge = Device.objects.get(name="edge-test") + + script = Script() + utils.set_device_platform(edge, PLATFORM_SLUG_JUNOS, script=script) + self.assertEqual(PLATFORM_SLUG_JUNOS, utils.get_device_platform_slug(edge)) + self.assertEqual( + f"Device {edge.name} already has platform (slug) {PLATFORM_SLUG_JUNOS} set.", + script.log[0][1], + ) + + script = Script() + utils.set_device_platform(pe, PLATFORM_SLUG_EOS, script=script) + self.assertEqual(PLATFORM_SLUG_EOS, utils.get_device_platform_slug(pe)) + self.assertEqual( + f"Set platform (slug) {PLATFORM_SLUG_EOS} to device {pe.name}", + script.log[0][1], + ) + + with self.assertRaisesRegex(InvalidInput, "Platform .* does not exist!"): + utils.set_device_platform( + edge, "somEtherneting-somEtherneting-does-not-exit" + ) + + def test_get_device_max_VRF_count(self): + pe = utils.get_device("pe-test") + + # No tag by default + with self.assertRaises( + NetBoxDataError, + msg=f"Should have raise NetBoxDataError!", + ): + utils.get_device_max_VRF_count(pe) + + tag, _ = utils.get_or_create_tag("NET:MaxVRFCount=123") + pe.device_type.tags.add(tag) + pe.device_type.save() + self.assertEqual(123, utils.get_device_max_VRF_count(pe)) + + ################################################################################ + # Interface related helper functions # + ################################################################################ + + def test_get_port_string(self): + iface = Interface.objects.all()[0] + port_desc_actual = utils.get_port_string(iface) + port_desc_expected = f"Interface {iface.name} on {iface.device.name}" + self.assertEqual(port_desc_actual, port_desc_expected) + + def test_get_remote_interface(self): + pe = Device.objects.get(name="pe-test") + pe_eth19 = Interface.objects.get(device=pe, name="Ethernet19") + pe_ae0 = Interface.objects.get(device=pe, name="ae0") + + edge = Device.objects.get(name="edge-test") + edge_et000 = Interface.objects.get(device=edge, name="et-0/0/0") + edge_ae0 = Interface.objects.get(device=edge, name="ae0") + + # PE/Ethernet19 is connected to edge/et-0/0/0, both are part of respective ae0 + self.assertEqual(edge_et000, utils.get_remote_interface_native(pe_eth19)) + self.assertEqual(pe_eth19, utils.get_remote_interface_native(edge_et000)) + + self.assertEqual(edge_ae0, utils.get_remote_interface(pe_ae0)) + self.assertEqual(pe_ae0, utils.get_remote_interface(edge_ae0)) + + # Empty LAG + ae1, _ = utils.get_or_create_LAG_interface_with_members(pe, "ae1", []) + self.assertIsNone(utils.get_remote_interface(ae1)) + + # Add unconnected Ethernet1 to the LAG + pe_eth1 = Interface.objects.get(device=pe, name="Ethernet1") + ae1, _ = utils.get_or_create_LAG_interface_with_members(pe, "ae1", [pe_eth1]) + self.assertIsNone(utils.get_remote_interface(pe_eth1)) + + def test_get_interface(self): + pe = Device.objects.get(name="pe-test") + + self.assertIsNotNone(utils.get_interface(pe, "Ethernet1")) + self.assertIsNone(utils.get_interface(pe, "does-not-exist")) + + def test_interface_types_compatible(self): + pe = Device.objects.get(name="pe-test") + pe_eth1 = Interface.objects.get(device=pe, name="Ethernet1") + + for iface_type in [ + InterfaceTypeChoices.TYPE_25GE_SFP28, + InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, + InterfaceTypeChoices.TYPE_10GE_X2, + InterfaceTypeChoices.TYPE_10GE_XENPAK, + InterfaceTypeChoices.TYPE_10GE_XFP, + ]: + self.assertTrue(utils.interface_types_compatible(pe_eth1, iface_type)) + + self.assertFalse( + utils.interface_types_compatible( + pe_eth1, InterfaceTypeChoices.TYPE_100GE_QSFP28 + ) + ) + + pe_eth19 = Interface.objects.get(device=pe, name="Ethernet19") + self.assertFalse( + utils.interface_types_compatible( + pe_eth19, InterfaceTypeChoices.TYPE_10GE_SFP_PLUS + ) + ) + + # Implicitly tests create_interface() + def test_get_or_create_interface(self): + pe = Device.objects.get(name="pe-test") + + pe_eth1, created = utils.get_or_create_interface( + pe, "Ethernet1", InterfaceTypeChoices.TYPE_25GE_SFP28 + ) + self.assertIsNotNone(pe_eth1) + self.assertFalse(created) + + new_iface, created = utils.get_or_create_interface( + pe, "new-iface", InterfaceTypeChoices.TYPE_100GE_QSFP28, "foo" + ) + self.assertIsNotNone(new_iface) + self.assertTrue(created) + self.assertEqual(new_iface.type, InterfaceTypeChoices.TYPE_100GE_QSFP28) + self.assertEqual(new_iface.description, "foo") + + # Also tests get_LAG_members() + def test_get_or_create_LAG_interface_with_members(self): + pe = Device.objects.get(name="pe-test") + pe_eth19 = utils.get_interface(pe, "Ethernet19") + pe_ae0 = utils.get_interface(pe, "ae0") + + self.assertListEqual([pe_eth19], utils.get_LAG_members(pe_ae0)) + + # "Ethernet1" is already part of "ae0" + with self.assertRaisesRegex( + NetBoxDataError, "should be placed in LAG .*, but is member of LAG" + ): + lag = utils.get_or_create_LAG_interface_with_members( + pe, "lag1", [pe_eth19], "bar" + ) + + # No-op, everything is already in place + lag, created = utils.get_or_create_LAG_interface_with_members( + pe, "ae0", [pe_eth19], "bar" + ) + self.assertEqual(pe_ae0, lag) + self.assertFalse(created) + self.assertEqual(InterfaceTypeChoices.TYPE_LAG, lag.type) + self.assertEqual(lag, pe_eth19.lag) + self.assertListEqual([pe_eth19], utils.get_LAG_members(pe_ae0)) + + # Add interface to existing LAG + pe_eth2 = utils.get_interface(pe, "Ethernet2") + lag, created = utils.get_or_create_LAG_interface_with_members( + pe, "ae0", [pe_eth2], "bar" + ) + self.assertEqual(pe_ae0, lag) + self.assertFalse(created) + self.assertEqual(InterfaceTypeChoices.TYPE_LAG, lag.type) + self.assertEqual(lag, pe_eth19.lag) + self.assertEqual(lag, pe_eth2.lag) + self.assertListEqual([pe_eth2, pe_eth19], utils.get_LAG_members(pe_ae0)) + + def test_create_next_available_LAG_interface(self): + pe = Device.objects.get(name="pe-test") + + # Only ae0 exists + next_lag = utils.create_next_available_LAG_interface( + pe, "new LAG", override_basename="ae" + ) + self.assertEqual("ae1", next_lag.name) + self.assertEqual(InterfaceTypeChoices.TYPE_LAG, next_lag.type) + self.assertEqual("new LAG", next_lag.description) + + # ae0, ae1, and ae3 exist + pe_ae3 = Interface(device=pe, name="ae3", type=InterfaceTypeChoices.TYPE_LAG) + pe_ae3.save() + next_lag = utils.create_next_available_LAG_interface( + pe, "new LAG", override_basename="ae" + ) + self.assertEqual("ae2", next_lag.name) + + # ae0 exists + edge = Device.objects.get(name="edge-test") + next_lag = utils.create_next_available_LAG_interface( + edge, + start_at=200, + # Figure out lag_basename automagically + ) + self.assertEqual("ae200", next_lag.name) + self.assertEqual(InterfaceTypeChoices.TYPE_LAG, next_lag.type) + self.assertEqual("", next_lag.description) + + # Not an integer + with self.assertRaisesRegex( + InvalidInput, "Invalid value for 'start_at' paramter, not an integer" + ): + utils.create_next_available_LAG_interface(edge, start_at={}) + + # int < 0 + with self.assertRaisesRegex( + InvalidInput, "Invalid value for 'start_at' paramter, needs to be > 0" + ): + utils.create_next_available_LAG_interface(edge, start_at=-1) + + # Unknown platform + plat = Platform(name="something-something", slug="something-something") + plat.save() + pe.platform = plat + pe.save() + with self.assertRaisesRegex( + NetBoxDataError, + "No LAG base config found for platform .* nor manufacturer .*!", + ): + utils.create_next_available_LAG_interface(pe) + + # Tests create_vlan_unit() + get_child_interfaces() + def test_units_and_child_interface(self): + pe = Device.objects.get(name="pe-test") + pe_test1 = Interface.objects.get(device=pe, name="Ethernet1") + + vlan42 = utils.get_interface(pe, "Ethernet1.42") + self.assertIsNone(vlan42) + + vlan42 = utils.create_vlan_unit(pe_test1, 42) + self.assertIsNotNone(vlan42) + self.assertEqual("Ethernet1.42", vlan42.name) + self.assertEqual(pe_test1, vlan42.parent) + self.assertEqual(InterfaceTypeChoices.TYPE_VIRTUAL, vlan42.type) + + with self.assertRaisesRegex(InvalidInput, "VLAN ID .* already configured"): + vlan42 = utils.create_vlan_unit(pe_test1, 42) + + children = utils.get_child_interfaces(pe_test1) + self.assertListEqual([vlan42], children) + + def test_tag_interfaces(self): + pe = Device.objects.get(name="pe-test") + pe_eth1 = Interface.objects.get(device=pe, name="Ethernet1") + pe_eth2 = Interface.objects.get(device=pe, name="Ethernet2") + + self.assertEqual(0, len(pe_eth1.tags.all())) + + # Tagging with an non-existing tag should fail + try: + utils.tag_interfaces([pe_eth1, pe_eth2], "Test Tag") + self.fail("Tagging interface with non-existing tag should fail") + except NetBoxDataError: + pass + + # Create tag and tag interfaces + tag = Tag(name="Test Tag", slug="test-tag") + tag.save() + + utils.tag_interfaces([pe_eth1, pe_eth2], "Test Tag") + + pe_eth1 = Interface.objects.get(device=pe, name="Ethernet1") + pe_eth2 = Interface.objects.get(device=pe, name="Ethernet2") + + self.assertTrue(tag in pe_eth1.tags.all() and len(pe_eth1.tags.all()) == 1) + self.assertTrue(tag in pe_eth2.tags.all() and len(pe_eth2.tags.all()) == 1) + + def test_assign_IP_address_to_interface(self): + pe = Device.objects.get(name="pe-test") + pe_eth1 = Interface.objects.get(device=pe, name="Ethernet1") + + # There should be 0 IPs configured + ips = IPAddress.objects.filter(interface=pe_eth1) + self.assertEqual(0, len(ips)) + + # Assign one IP + utils.assign_IP_address_to_interface(pe_eth1, "192.0.2.42/31", None) + ips = IPAddress.objects.filter(interface=pe_eth1) + self.assertEqual(1, len(ips)) + self.assertEqual("192.0.2.42/31", str(ips[0])) + + # Assign the same IP again, which should be a no-op + utils.assign_IP_address_to_interface(pe_eth1, "192.0.2.42/31", None) + ips = IPAddress.objects.filter(interface=pe_eth1) + self.assertEqual(1, len(ips)) + self.assertEqual("192.0.2.42/31", str(ips[0])) + self.assertEqual({}, ips[0].custom_field_data) + + # Validate custom field handling + pe_test2 = Interface.objects.get(device=pe, name="Ethernet2") + # There should be 0 IPs configured + ips = IPAddress.objects.filter(interface=pe_test2) + self.assertEqual(0, len(ips)) + + cf_data = { + "gateway_ip": "192.0.2.22", + } + utils.assign_IP_address_to_interface(pe_test2, "192.0.2.23/31", cf_data) + ips = IPAddress.objects.filter(interface=pe_test2) + self.assertEqual(1, len(ips)) + self.assertEqual("192.0.2.23/31", str(ips[0])) + self.assertEqual(cf_data, ips[0].custom_field_data) + + ################################################################################ + # IPAM related helper functions # + ################################################################################ + + def test_get_prefixes(self): + test_net_1 = Prefix.objects.get(prefix="192.2.0.0/24") + test_net_2 = Prefix.objects.get(prefix="198.51.100.0/24") + ipv6_pfx = Prefix.objects.get(prefix="2001:db8:2342::/48") + + self.assertEqual( + [], + utils.get_prefixes( + PREFIX_ROLE_SLUG_SITE_LOCAL, utils.AF_IPv6, custom_fields={"pop": 123} + ), + ) + + pfxs_v4 = utils.get_prefixes(PREFIX_ROLE_SLUG_SITE_LOCAL, utils.AF_IPv4) + self.assertEqual([test_net_1, test_net_2], pfxs_v4) + + pfxs_v6 = utils.get_prefixes(PREFIX_ROLE_SLUG_SITE_LOCAL, utils.AF_IPv6) + self.assertEqual([ipv6_pfx], pfxs_v6) + + # Explicitly check with is_container set + pfxs = utils.get_prefixes( + PREFIX_ROLE_SLUG_SITE_LOCAL, utils.AF_IPv4, is_container=True + ) + self.assertEqual([test_net_1, test_net_2], pfxs) + + # w/ Tag + pfxs = utils.get_prefixes( + PREFIX_ROLE_SLUG_SITE_LOCAL, + utils.AF_IPv4, + is_container=True, + tag_name="NET:PFX:PE_LOOPBACKS:test-a", + ) + self.assertEqual([test_net_1], pfxs) + + # w/ CF + pfxs = utils.get_prefixes( + PREFIX_ROLE_SLUG_SITE_LOCAL, + utils.AF_IPv4, + is_container=True, + custom_fields={"pop": 2342}, + ) + self.assertEqual([test_net_1], pfxs) + + # w/ CF & tag + pfxs = utils.get_prefixes( + PREFIX_ROLE_SLUG_SITE_LOCAL, + utils.AF_IPv4, + is_container=True, + custom_fields={"pop": 2342}, + tag_name="NET:PFX:PE_LOOPBACKS:test-a", + ) + self.assertEqual([test_net_1], pfxs) + + # Also test get_prefix_role() + # TODO: Check side-effects (warning logs) + def test_get_or_create_prefix(self): + self.assertIsNone(utils.get_prefix_role("foo")) + + role = Role(name="Test Role", slug="test-role") + role.save() + + r = utils.get_prefix_role("test-role") + self.assertEqual(role, r) + + # Make sure prefix doesn't exist + ip_network = netaddr.ip.IPNetwork("192.0.2.0/24") + self.assertEqual(0, len(Prefix.objects.filter(prefix=ip_network))) + + # Create Prefix with Role, from str + pfx, created = utils.get_or_create_prefix("192.0.2.0/24", "Test Prefix", role) + self.assertTrue(created) + self.assertEqual(ip_network, pfx.prefix) + self.assertEqual("Test Prefix", pfx.description) + self.assertEqual(role, pfx.role) + + # Try to re-create the same prefix + pfx, created = utils.get_or_create_prefix("192.0.2.0/24", "Test Prefix", role) + self.assertFalse(created) + self.assertEqual(ip_network, pfx.prefix) + self.assertEqual("Test Prefix", pfx.description) + self.assertEqual(role, pfx.role) + + # Create 2nd prefix without Role, from IPNetwork obj + ip_network = netaddr.ip.IPNetwork("192.0.2.0/25") + self.assertEqual(0, len(Prefix.objects.filter(prefix=ip_network))) + pfx, created = utils.get_or_create_prefix( + ip_network, + "Test Prefix #2", + ) + self.assertTrue(created) + self.assertEqual(ip_network, pfx.prefix) + self.assertEqual("Test Prefix #2", pfx.description) + self.assertEqual(None, pfx.role) + + def test_get_or_create_prefix_from_ip(self): + ip_obj = IPAddress(address="192.0.2.0/31") + ip_obj.save() + + role = Role(name="Test Role", slug="test-role") + role.save() + + # Create prefix from IP with Role + pfx, created = utils.get_or_create_prefix_from_ip( + ip_obj, "IP from IPAddress", role + ) + self.assertTrue(created) + self.assertEqual(netaddr.ip.IPNetwork("192.0.2.0/31"), pfx.prefix) + self.assertEqual("IP from IPAddress", pfx.description) + self.assertEqual(role, pfx.role) + + # Create Prefix from IPNetwork (already exists) + pfx, created = utils.get_or_create_prefix_from_ip( + netaddr.ip.IPNetwork("192.0.2.1/31"), "IP from IPNetwork", role + ) + self.assertFalse(created) + self.assertEqual(netaddr.ip.IPNetwork("192.0.2.0/31"), pfx.prefix) + self.assertEqual("IP from IPAddress", pfx.description) + self.assertEqual(role, pfx.role) + + # Create Prefix from IP str + pfx, created = utils.get_or_create_prefix_from_ip( + "192.0.2.2/31", + "IP from str", + ) + self.assertTrue(created) + self.assertEqual(netaddr.ip.IPNetwork("192.0.2.2/31"), pfx.prefix) + self.assertEqual("IP from str", pfx.description) + self.assertEqual(None, pfx.role) + + # test get_interface_IPs() + get_interface_IP() + def test_get_interface_IPs(self): + pe = Device.objects.get(name="pe-test") + pe_eth1 = Interface.objects.get(device=pe, name="Ethernet1") + + # There should be 0 IPs configured + ips = IPAddress.objects.filter(interface=pe_eth1) + self.assertEqual(0, len(ips)) + + # Assign one IP + utils.assign_IP_address_to_interface(pe_eth1, "192.0.2.42/31", None) + + ips = utils.get_interface_IPs(pe_eth1) + self.assertEqual(1, len(ips)) + self.assertEqual("192.0.2.42/31", str(ips[0])) + + ip = utils.get_interface_IP(pe_eth1, "192.0.2.42/31") + self.assertEqual("192.0.2.42/31", str(ip)) + + ip = utils.get_interface_IP(pe_eth1, "192.0.2.23/31") + self.assertIsNone(ip) + + def test_get_next_free_IP_from_pools(self): + self.assertIsNone(utils.get_next_free_IP_from_pools([])) + + pe_lo_pools = utils.get_prefixes( + PREFIX_ROLE_SLUG_LOOPBACK_IPS, + utils.AF_IPv6, + is_container=True, + custom_fields={"pop": 2342}, + ) + + # Regular v6 pool + ip_v6_0 = utils.get_next_free_IP_from_pools(pe_lo_pools) + self.assertIsNotNone(ip_v6_0) + self.assertEqual("2001:db8:2342:ffff::/128", str(ip_v6_0)) + + ip_v6_1 = utils.get_next_free_IP_from_pools(pe_lo_pools) + self.assertIsNotNone(ip_v6_1) + self.assertEqual("2001:db8:2342:ffff::1/128", str(ip_v6_1)) + + # Small v4 pool to check edge cases + v4_pool = utils.get_prefixes( + PREFIX_ROLE_SLUG_LOOPBACK_IPS, + utils.AF_IPv4, + is_container=True, + custom_fields={"pop": 2342}, + ) + + ip_v4_0 = utils.get_next_free_IP_from_pools(v4_pool) + self.assertIsNotNone(ip_v4_0) + self.assertEqual("192.168.0.0/32", str(ip_v4_0)) + + ip_v4_1 = utils.get_next_free_IP_from_pools(v4_pool) + self.assertIsNotNone(ip_v6_1) + self.assertEqual("192.168.0.1/32", str(ip_v4_1)) + + self.assertIsNone(utils.get_next_free_IP_from_pools(v4_pool)) + + def test_get_next_free_prefix_from_prefixes(self): + with self.assertRaisesRegex(InvalidInput, "Prefix role .* does not exist"): + utils.get_next_free_prefix_from_prefixes( + [], + 18, + "Some fancy description", + "Non-existing prefix role", + ) + + test_net_1 = Prefix.objects.get(prefix="192.2.0.0/24") + test_net_2 = Prefix.objects.get(prefix="198.51.100.0/24") + containers = [test_net_1, test_net_2] + + new_pfx_26 = utils.get_next_free_prefix_from_prefixes( + containers, + 26, + "Slash Twentysix", + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + is_pool=False, + ) + self.assertIsNotNone(new_pfx_26) + self.assertEqual("192.2.0.0/26", str(new_pfx_26)) + self.assertEqual("Slash Twentysix", new_pfx_26.description) + self.assertEqual(26, new_pfx_26.mask_length) + self.assertEqual(PREFIX_ROLE_SLUG_TRANSFER_NETWORK, new_pfx_26.role.slug) + + new_pfx_25 = utils.get_next_free_prefix_from_prefixes( + containers, + 25, + "Slash Twentyfive", + is_pool=True, + custom_fields={"pop": 2342}, + ) + self.assertIsNotNone(new_pfx_25) + self.assertEqual("192.2.0.128/25", str(new_pfx_25)) + self.assertEqual("Slash Twentyfive", new_pfx_25.description) + self.assertEqual(25, new_pfx_25.mask_length) + self.assertIsNone(new_pfx_25.role) + self.assertEqual(2342, new_pfx_25.custom_field_data["pop"]) + + new_pfx_24 = utils.get_next_free_prefix_from_prefixes( + containers, + 24, + "Slash Twentyfour", + ) + self.assertIsNone(new_pfx_24) + + def test_get_next_free_prefix_from_container_fail(self): + with self.assertRaisesRegex(InvalidInput, "Prefix role .* does not exist"): + utils.get_next_free_prefix_from_container( + "does-not-exist", + utils.AF_IPv4, + 18, + "Too big too not fail", + ) + + with self.assertRaisesRegex(InvalidInput, "No container prefix found for role"): + utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv6, + 127, + "No site-locals", + ) + + # container exists, but not w/ tag + with self.assertRaisesRegex( + InvalidInput, "No container prefix found for role .* and tag" + ): + utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv4, + 31, + "No site-locals", + container_tag_name="does-not-exist", + ) + + # container exists, but not w/ tag & CF + with self.assertRaisesRegex( + InvalidInput, + "No container prefix found for role .* and tag .* and custom fields", + ): + utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv4, + 31, + "No site-locals", + container_tag_name="does-not-exist", + container_custom_fields={"pop": 123}, + ) + + with self.assertRaisesRegex(InvalidInput, "Prefix role .* does not exist"): + utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv4, + 31, + "No site-locals", + prefix_role_slug="does-not-exist", + ) + + # Requesting a /17 from a /18 should fail + with self.assertRaisesRegex(NetBoxDataError, "but no free prefix available"): + utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv4, + 17, + "Too big too not fail", + ) + + def test_get_next_free_prefix_from_container_OK(self): + new_pfx_26 = utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv4, + 26, + "Fancy new Prefix", + is_pool=True, + ) + self.assertEqual(26, new_pfx_26.mask_length) + self.assertEqual("Fancy new Prefix", new_pfx_26.description) + self.assertTrue(new_pfx_26.is_pool) + self.assertEqual("100.64.0.0/26", str(new_pfx_26)) + + # Requesting a /25 should skip 100.64.0.64/26 and lead to 100.64.0.128/25 + new_pfx_25 = utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + utils.AF_IPv4, + 25, + "Slash Twentyfive", + ) + self.assertEqual(25, new_pfx_25.mask_length) + self.assertEqual("Slash Twentyfive", new_pfx_25.description) + self.assertFalse(new_pfx_25.is_pool) + self.assertEqual("100.64.0.128/25", str(new_pfx_25)) + + # Requesting /31 transfer-network from container w/ tag + site_local_pfx = utils.get_next_free_prefix_from_container( + PREFIX_ROLE_SLUG_SITE_LOCAL, + utils.AF_IPv4, + 31, + "Thirtyone", + container_tag_name="NET:PFX:PE_LOOPBACKS:test-a", + is_pool=False, + prefix_role_slug=PREFIX_ROLE_SLUG_TRANSFER_NETWORK, + ) + self.assertEqual("192.2.0.0/31", str(site_local_pfx)) + self.assertEqual(PREFIX_ROLE_SLUG_TRANSFER_NETWORK, site_local_pfx.role.slug) + + def test_get_IPs_from_IPSet(self): + self.assertEqual( + [ + netaddr.IPNetwork("2001:db8:2342:FE00::/127"), + netaddr.IPNetwork("2001:db8:2342:FE00::1/127"), + ], + utils.get_IPs_from_IPSet(netaddr.IPSet(["2001:db8:2342:FE00::/127"]), 127), + ) + + self.assertEqual( + [netaddr.IPNetwork("192.0.2.0/31"), netaddr.IPNetwork("192.0.2.1/31")], + utils.get_IPs_from_IPSet(netaddr.IPSet(["192.0.2.0/31"]), 31), + ) + + ################################################################################ + # Tag related helper functions # + ################################################################################ + + def test_get_tag(self): + tag = utils.get_tag("Test Tag") + self.assertIsNone(tag) + + # Create tag and tag interfaces + t = Tag(name="Test Tag", slug="test-tag") + t.save() + + tag = utils.get_tag("Test Tag") + self.assertEqual(t, tag) + + def test_get_or_create_tag(self): + self.assertIsNone(utils.get_tag("Test Tag")) + + tag, created = utils.get_or_create_tag("Test Tag", ColorChoices.COLOR_PURPLE) + self.assertTrue(created) + self.assertEqual("Test Tag", tag.name) + self.assertEqual(ColorChoices.COLOR_PURPLE, tag.color) + + tag, created = utils.get_or_create_tag("Test Tag", ColorChoices.COLOR_PURPLE) + self.assertFalse(created) + self.assertEqual("Test Tag", tag.name) + self.assertEqual(ColorChoices.COLOR_PURPLE, tag.color) diff --git a/scripts/helper_library/tests/test_validators.py b/scripts/helper_library/tests/test_validators.py new file mode 100644 index 0000000..6f0a97e --- /dev/null +++ b/scripts/helper_library/tests/test_validators.py @@ -0,0 +1,190 @@ +#!/usr/bin/python3 + + +from django.test import TestCase +import netaddr + +from dcim.models import ( + Device, + DeviceRole, + DeviceType, + Module, + ModuleBay, + ModuleType, + Platform, + Site, +) +from dcim.models.device_components import Interface + +from scripts.common.errors import InvalidInput +import scripts.common.validators as validators + + +class ScriptTestCase(TestCase): + fixtures = [ + "/opt/fixtures/templates.json", + ] + + @classmethod + def setUpTestData(cls): + """Run once per TestCase instance.""" + edge = Device( + name="edge-test", + device_type=DeviceType.objects.get(model="QFX10008"), + device_role=DeviceRole.objects.get(slug="edge-router"), + platform=Platform.objects.get(slug="junos"), + site=Site.objects.get(name="NET-META-ANY"), + ) + edge.save() + fpc0 = Module( + device=edge, + module_bay=ModuleBay.objects.get(device=edge, name="FPC0"), + module_type=ModuleType.objects.get(model="QFX10000-30C"), + ) + fpc0.save() + + edge_no_ifaces = Device( + name="edge-no-ifaces", + device_type=DeviceType.objects.get(model="QFX10008"), + device_role=DeviceRole.objects.get(slug="edge-router"), + platform=Platform.objects.get(slug="junos"), + site=Site.objects.get(name="NET-META-ANY"), + ) + edge_no_ifaces.save() + + ################################################################################ + # Validators for single values # + ################################################################################ + + def test_validate_IP(self): + validators.validate_IP("test", "192.0.2.42") + validators.validate_IP("test", "2001:db8:23::42") + + for ip in [ + "192.0.2.256", + "192.0.2.24/23", + "2001:db8::/64", + "2001:db8:abcd:efgh", + ]: + with self.assertRaisesRegex(InvalidInput, "is not a valid IP address"): + validators.validate_IP("test", ip) + + def test_validate_prefix(self): + validators.validate_prefix("test", "192.0.2.42/23") + validators.validate_prefix("test", "2001:db8:23::42/64") + + for pfx in [ + "192.0.2.256", + "192.0.2.24", + "192.0.2.24/33", + "2001:db8:23::42", + "2001:db8:abcd:efgh", + "2001:db8::/129", + ]: + with self.assertRaisesRegex(InvalidInput, "is not a valid IP prefix!"): + validators.validate_prefix("test", pfx) + + def test_validate_device_name(self): + validators.validate_device_name("test", "edge-test") + with self.assertRaisesRegex( + InvalidInput, + "Given device name .* does not exist!", + ): + validators.validate_device_name("test", "does-not-exist") + + def test_validate_VLAN_ID(self): + validators.validate_VLAN_ID("test", 1) + validators.validate_VLAN_ID("test", 42) + validators.validate_VLAN_ID("test", 4096) + + for vid in ["abc", "-1", "0", "4097"]: + with self.assertRaisesRegex(InvalidInput, "is not a valid VLAN ID"): + validators.validate_VLAN_ID("test", vid) + + def test_validate_bool(self): + validators.validate_bool("test", True) + validators.validate_bool("test", False) + + with self.assertRaisesRegex(InvalidInput, "is not a valid boolean"): + validators.validate_bool("test", None) + with self.assertRaisesRegex(InvalidInput, "is not a valid boolean"): + validators.validate_bool("test", "abc") + with self.assertRaisesRegex(InvalidInput, "is not a valid boolean"): + validators.validate_bool("test", 0) + with self.assertRaisesRegex(InvalidInput, "is not a valid boolean"): + validators.validate_bool("test", {}) + + def test_validate_VLAN_ID(self): + validators.validate_ASN("test", 1) + validators.validate_ASN("test", 42) + validators.validate_ASN("test", 4294967296) + + for asn in ["abc", "-1", "0", "4294967297"]: + with self.assertRaisesRegex(InvalidInput, "is not a valid ASN"): + validators.validate_ASN("test", asn) + + ################################################################################ + # Validators to be applied manually # + ################################################################################ + + def test_validate_IP_within_subnet(self): + validators.validate_IP_within_subnet("192.2.0.42", "192.2.0.0/24") + validators.validate_IP_within_subnet("2001:db8::42", "2001:db8::/64") + validators.validate_IP_within_subnet( + netaddr.IPNetwork("192.2.0.42/24"), netaddr.IPNetwork("192.2.0.0/24") + ) + + with self.assertRaisesRegex(InvalidInput, "Invalid prefix .*, no / present!"): + validators.validate_IP_within_subnet("192.0.2.42", "192.0.0.0") + + with self.assertRaisesRegex(InvalidInput, "Failed to parse IP .* or prefix"): + validators.validate_IP_within_subnet( + "2001:db8::abcd:efgh", "2001:db8:23::/64" + ) + + with self.assertRaisesRegex(InvalidInput, "Failed to parse IP .* or prefix"): + validators.validate_IP_within_subnet("192.0.2.42/25", "192.0.2.0/24") + + fail_subnet = { + "192.0.2.42": "192.0.0.0/24", + "2001:db8::42": "2001:db8:23::/64", + } + for ip, pfx in fail_subnet.items(): + with self.assertRaisesRegex( + InvalidInput, "IP address .* does not belong to subnet" + ): + validators.validate_IP_within_subnet(ip, pfx) + + def test_validate_prefixes_within_same_subnet(self): + validators.validate_prefixes_within_same_subnet( + "192.0.2.23/24", "192.0.2.42/24" + ) + validators.validate_prefixes_within_same_subnet( + "2001:db8::23/64", "2001:db8::42/64" + ) + + fail_combinations = { + "192.0.2.23/24": "192.0.2.42/33", + "192.0.2.23/24": "192.0.2.42/25", + "192.0.2.23/24": "192.0.3.42/24", + "2001:db8:23::/64": "2001:db8:42::/64", + "2001:db8::1/127": "2001:db8::2/127", + } + + for ip1, ip2 in fail_combinations.items(): + with self.assertRaisesRegex( + InvalidInput, "are not part of the same subnet" + ): + validators.validate_prefixes_within_same_subnet(ip1, ip2) + + def test_validate_device_interface(self): + edge = Device.objects.get(name="edge-test") + edge_et000 = Interface.objects.get(name="et-0/0/0") + validators.validate_device_interface(edge, edge_et000) + + edge = Device.objects.get(name="edge-no-ifaces") + + with self.assertRaisesRegex( + InvalidInput, "Interface .* does not belong to device" + ): + validators.validate_device_interface(edge, edge_et000) diff --git a/scripts/helper_library/tests/testutils.py b/scripts/helper_library/tests/testutils.py new file mode 100644 index 0000000..40b3c0a --- /dev/null +++ b/scripts/helper_library/tests/testutils.py @@ -0,0 +1,36 @@ +#!/usr/bin/python3 + +"""Utils for setting up test data in the DB.""" + +from dcim.models import ( + Device, + DeviceRole, + DeviceType, + Platform, + Site, +) + + +def create_device( + name: str, model: str, devrole_slug: str, site_name: str, platform_slug: str = None +) -> Device: + """Create""" + if platform_slug: + dev = Device( + name=name, + device_type=DeviceType.objects.get(model=model), + device_role=DeviceRole.objects.get(slug=devrole_slug), + site=Site.objects.get(name=site_name), + platform=Platform.objects.get(slug=platform_slug), + ) + else: + dev = Device( + name=name, + device_type=DeviceType.objects.get(model=model), + device_role=DeviceRole.objects.get(slug=devrole_slug), + site=Site.objects.get(name=site_name), + ) + + dev.save() + + return dev