Skip to content

Commit 14a1065

Browse files
New Module: Created route_targets module and updated docs as going through (#422)
1 parent 9155ffb commit 14a1065

File tree

21 files changed

+1435
-12
lines changed

21 files changed

+1435
-12
lines changed

docs/getting_started/contributing.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
=========================================
2+
Contributing to the Collection
3+
=========================================
4+
5+
.. toctree::
6+
:maxdepth: 4
7+
8+
Contributing <contributing/index>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
==============================================
2+
Contributing to the Ansible Collection
3+
==============================================
4+
5+
.. toctree::
6+
:maxdepth: 4
7+
8+
Modules <modules/index>
9+
Inventory <inventory/index>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
==============================================
2+
Contributing Updates to the Inventory Plugin
3+
==============================================
4+
5+
.. toctree::
6+
:maxdepth: 4
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
==========================
2+
Module Architecture
3+
==========================
4+
5+
Overview
6+
----------------------
7+
8+
NetBox uses Django apps to break up each data domain within NetBox. An example of this is **circuits**, **dcim**, **ipam**, **tenancy**, etc. Each application then implements endpoints that fit under the parent application.
9+
An example of an endpoint is **devices** living under the **dcim** app and **ip addresses** living under the **ipam** app. This collection takes the same approach with organizing the module utils for each application and then the endpoints are implemented as the Ansible modules.
10+
11+
Let's take a look at the output of the ``tree`` command within the ``plugins/`` directory.
12+
13+
.. code-block:: bash
14+
15+
├── plugins
16+
│ ... omitted
17+
│ ├── module_utils
18+
│ │ ├── netbox_circuits.py
19+
│ │ ├── netbox_dcim.py
20+
│ │ ├── netbox_extras.py
21+
│ │ ├── netbox_ipam.py
22+
│ │ ├── netbox_secrets.py
23+
│ │ ├── netbox_tenancy.py
24+
│ │ ├── netbox_utils.py
25+
│ │ └── netbox_virtualization.py
26+
│ └── modules
27+
│ ... omitted
28+
│ ├── netbox_device.py
29+
│ ... omitted
30+
│ └── netbox_vrf.py
31+
32+
128 directories, 357 files
33+
34+
As you can see, we have a handful of ``module_utils`` that correspond to each application in **NetBox** as well as a ``netbox_utils`` module that provides a common interface for the collection.
35+
36+
Let's start by taking a look at the specifics of what each application module util is accomplishing.
37+
38+
Module Util Apps (dcim, etc.)
39+
++++++++++++++++++++++++++++++
40+
41+
These utility modules contain most of the logic when it comes to interacting with the NetBox API. There is a lot of overlap between what the modules need to do to interact with the NetBox API. Therefore, it's wise
42+
to try and reduce the boiler plate as much as possible. Within each application module, there is similar code for finding the object within NetBox, but different options depending on some of the module
43+
arguments provided to the user and what fields are available on any given endpoint.
44+
45+
Let's take a look at some of the code within ``netbox_dcim.py``.
46+
47+
.. code-block:: python
48+
49+
# -*- coding: utf-8 -*-
50+
# Copyright: (c) 2018, Mikhail Yohman (@fragmentedpacket) <mikhail.yohman@gmail.com>
51+
# Copyright: (c) 2020, Nokia, Tobias Groß (@toerb) <tobias.gross@nokia.com>
52+
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
53+
from __future__ import absolute_import, division, print_function
54+
55+
__metaclass__ = type
56+
57+
from ansible_collections.netbox.netbox.plugins.module_utils.netbox_utils import (
58+
NetboxModule,
59+
ENDPOINT_NAME_MAPPING,
60+
SLUG_REQUIRED,
61+
)
62+
63+
NB_CABLES = "cables"
64+
NB_CONSOLE_PORTS = "console_ports"
65+
NB_CONSOLE_PORT_TEMPLATES = "console_port_templates"
66+
...
67+
68+
The top of the code is importing the ``NetboxModule`` class, ``ENDPOINT_NAME_MAPPING``, and ``SLUG_REQUIRED`` from ``netbox_utils.py``.
69+
70+
After the imports, we define constants to define the endpoints that are supported as well as these being passed into the initialization of ``NetboxModule``. We'll see these within the actual modules themselves when we take a look later.
71+
72+
Now let's take a look at the class definition.
73+
74+
.. code-block:: python
75+
76+
class NetboxDcimModule(NetboxModule):
77+
def __init__(self, module, endpoint):
78+
super().__init__(module, endpoint)
79+
80+
def run(self):
81+
...
82+
83+
We see that we're subclassing the ``NetboxModule`` here for ``NetboxDcimModule`` and then defining our own ``__init__`` method and then calling the ``__init__`` method of the parent class (``NetboxModule``). We'll
84+
cover the parent ``__init__`` class in a below section.
85+
86+
.. note:: This is not necessarily required, but provides flexibility in the future if we need to perform any operations prior to the parent ``__init__``.
87+
88+
After that, we define the ``run`` method. This method has to be implemented in all module utils and is part of the parent class that raises the ``NotImplementedError`` exception if not defined on the child class.
89+
The ``run`` method contains all the logic for executing the module and we'll start to dissect it below.
90+
91+
.. code-block:: python
92+
93+
def run(self):
94+
...
95+
# Used to dynamically set key when returning results
96+
endpoint_name = ENDPOINT_NAME_MAPPING[self.endpoint]
97+
98+
self.result = {"changed": False}
99+
100+
application = self._find_app(self.endpoint)
101+
nb_app = getattr(self.nb, application)
102+
nb_endpoint = getattr(nb_app, self.endpoint)
103+
user_query_params = self.module.params.get("query_params")
104+
105+
We take the value of the constant that was passed in and assigned to ``self.endpoint`` and grab the endpoint name that will be used within ``self.result``. We'll see it being used shortly.
106+
107+
.. code-block:: python
108+
109+
ENDPOINT_NAME_MAPPING = {
110+
...
111+
"devices": "device",
112+
...
113+
}
114+
115+
Now we move onto setting ``application`` and this is where we start to use methods that are available on the ``NetboxModule`` class. As you can see, we pass in the ``self.endpoint`` again
116+
to this method. Let's take a look at the method.
117+
118+
.. code-block:: python
119+
120+
# Used to map endpoints to applications dynamically
121+
API_APPS_ENDPOINTS = dict(
122+
circuits=["circuits", "circuit_types", "circuit_terminations", "providers"],
123+
dcim=[
124+
...
125+
"devices",
126+
...
127+
]
128+
)
129+
...
130+
class NetboxModule(object):
131+
...
132+
def _find_app(self, endpoint):
133+
"""Dynamically finds application of endpoint passed in using the
134+
API_APPS_ENDPOINTS for mapping
135+
:returns nb_app (str): The application the endpoint lives under
136+
:params endpoint (str): The endpoint requiring resolution to application
137+
"""
138+
for k, v in API_APPS_ENDPOINTS.items():
139+
if endpoint in v:
140+
nb_app = k
141+
return nb_app
142+
143+
This will determine which app the endpoint is part of dynamically and is reused throughout the collection.
144+
145+
We can see that **devices** is part of the **dcim** application. We then use that the set grab the **application** attribute from ``pynetbox`` and then follow that down to the endpoint level.
146+
147+
``nb_endpoint`` is set to ``self.nb.dcim.devices`` which provides several methods to **get**, **filter**, etc. on the endpoint to figure out if the user defined object already exists within NetBox.
148+
149+
After that, ``user_query_params`` is set and that will be either a list of user defined query params or ``None``. This topic is covered more in :ref:`Using query_params Module Argument`.
150+
151+
Let's take a look at the next block of code.
152+
153+
.. code-block:: python
154+
155+
def run(self):
156+
...
157+
data = self.data
158+
159+
# Used for msg output
160+
if data.get("name"):
161+
name = data["name"]
162+
elif data.get("model") and not data.get("slug"):
163+
name = data["model"]
164+
elif data.get("master"):
165+
name = self.module.params["data"]["master"]
166+
elif data.get("slug"):
167+
name = data["slug"]
168+
...
169+
170+
We then assign the data instance to ``data`` that will be used throughout the end of the ``run`` method. Next wee need to assign the name variable for future use when attempting
171+
to obtain the object from NetBox and this can live under several different fields which is the logic you see above.
172+
173+
Now we move onto some more data manipulation to prepare the payload for NetBox.
174+
175+
.. code-block:: python
176+
177+
def run(self):
178+
...
179+
if self.endpoint in SLUG_REQUIRED:
180+
if not data.get("slug"):
181+
data["slug"] = self._to_slug(name)
182+
183+
# Make color params lowercase
184+
if data.get("color"):
185+
data["color"] = data["color"].lower()
186+
187+
We're using the ``SLUG_REQUIRED`` constant that we imported above from ``netbox_utils`` to determine if the endpoint requires a slug when creating it. If the endpoint requires a **slug** and the user has not provided
188+
a slug then we set it for the user by using the ``_to_slug`` method on ``NetboxModule`` that uses the same logic NetBox does. We also make sure that **color** is lowercase if provided.
189+
190+
Here is some more endpoint specific logic that we aren't going to cover, but provides a good example of what some modules may implement when the normal flow does not work for the endpoint.
191+
192+
.. code-block:: python
193+
194+
def run(self):
195+
...
196+
if self.endpoint == "cables":
197+
cables = [
198+
cable
199+
for cable in nb_endpoint.all()
200+
if cable.termination_a_type == data["termination_a_type"]
201+
and cable.termination_a_id == data["termination_a_id"]
202+
and cable.termination_b_type == data["termination_b_type"]
203+
and cable.termination_b_id == data["termination_b_id"]
204+
]
205+
if len(cables) == 0:
206+
self.nb_object = None
207+
elif len(cables) == 1:
208+
self.nb_object = cables[0]
209+
else:
210+
self._handle_errors(msg="More than one result returned for %s" % (name))
211+
else:
212+
object_query_params = self._build_query_params(
213+
endpoint_name, data, user_query_params
214+
)
215+
self.nb_object = self._nb_endpoint_get(
216+
nb_endpoint, object_query_params, name
217+
)
218+
219+
The code after ``else:`` is what we're interested in and how most modules will determine if the object currently exists within NetBox or not. The query parameters are dynamically built
220+
by providing the ``endpoint_name``, ``data`` passed in by the user, and the ``user_query_params`` if provided by the user. Once the query parameters are built, we then attempt to fetch the
221+
object from NetBox.
222+
223+
.. code-block:: python
224+
225+
def run(self):
226+
...
227+
if self.state == "present":
228+
self._ensure_object_exists(nb_endpoint, endpoint_name, name, data)
229+
230+
elif self.state == "absent":
231+
self._ensure_object_absent(endpoint_name, name)
232+
233+
try:
234+
serialized_object = self.nb_object.serialize()
235+
except AttributeError:
236+
serialized_object = self.nb_object
237+
238+
self.result.update({endpoint_name: serialized_object})
239+
240+
self.module.exit_json(**self.result)
241+
242+
Depending on the state that the user defined, it will use helper functions to complete the intended state of the object. If those don't fail the module, it will then attempt to serialize
243+
the object before updating the ``self.result`` object and then exiting the module.
244+
245+
Most of the app module utils will have the same pattern, but can either have more or less code within it depending on the complexity of the endpoints implemented.
246+
247+
NetboxModule (__init__)
248+
+++++++++++++++++++++++++++++
249+
250+
The ``NetboxModule`` is the cornerstone of this collection and contains most of the methods required to build a module, but we're going to focus on what happens within the ``__init__`` method.
251+
252+
.. code-block:: python
253+
254+
class NetboxModule(object):
255+
"""
256+
Initialize connection to Netbox, sets AnsibleModule passed in to
257+
self.module to be used throughout the class
258+
:params module (obj): Ansible Module object
259+
:params endpoint (str): Used to tell class which endpoint the logic needs to follow
260+
:params nb_client (obj): pynetbox.api object passed in (not required)
261+
"""
262+
263+
def __init__(self, module, endpoint, nb_client=None):
264+
self.module = module
265+
self.state = self.module.params["state"]
266+
self.check_mode = self.module.check_mode
267+
self.endpoint = endpoint
268+
query_params = self.module.params.get("query_params")
269+
270+
if not HAS_PYNETBOX:
271+
self.module.fail_json(
272+
msg=missing_required_lib("pynetbox"), exception=PYNETBOX_IMP_ERR
273+
)
274+
275+
The ``__init__`` method requires an `~ansible.module_utils.basic.AnsibleModule` instance and the endpoint name to be provided with a `~pynetbox.api` client being optional.
276+
277+
We set several instance attributes that are used within other methods throughout the life of the instance. After that, we check to make sure the user has ``pynetbox`` installed and fail if not.
278+
279+
.. code-block:: python
280+
281+
class NetboxModule(object):
282+
...
283+
# These should not be required after making connection to Netbox
284+
url = self.module.params["netbox_url"]
285+
token = self.module.params["netbox_token"]
286+
ssl_verify = self.module.params["validate_certs"]
287+
288+
# Attempt to initiate connection to Netbox
289+
if nb_client is None:
290+
self.nb = self._connect_netbox_api(url, token, ssl_verify)
291+
else:
292+
self.nb = nb_client
293+
try:
294+
self.version = self.nb.version
295+
except AttributeError:
296+
self.module.fail_json(msg="Must have pynetbox >=4.1.0")
297+
298+
Next we set variables to be used to instantiate the ``pynetbox`` client if one was not passed in. After instantiated, it will set the NetBox version that helps determine how
299+
specific portions of the code should act depending on the NetBox version.
300+
301+
.. code-block:: python
302+
303+
class NetboxModule(object):
304+
...
305+
# These methods will normalize the regular data
306+
cleaned_data = self._remove_arg_spec_default(module.params["data"])
307+
norm_data = self._normalize_data(cleaned_data)
308+
choices_data = self._change_choices_id(self.endpoint, norm_data)
309+
data = self._find_ids(choices_data, query_params)
310+
self.data = self._convert_identical_keys(data)
311+
312+
The next few lines manipulate the data and prepare it for sending to NetBox.
313+
314+
- Removes argument spec defaults that Ansible sets if an option is not specified (``None``)
315+
- Normalizes data depending on the type of search it will use for the field
316+
- Changes choice for any fields that have choices provided by NetBox (e.g. status, type, etc.)
317+
- Find IDs of any child objects that need exist in NetBox before creating parent object (e.g. Device role)
318+
- Converts any fields that are namespaced to prevent conflicts when searching for them (e.g. device_role, ipam_role, rack_group, etc.)
319+
320+
If all those pass, it sets the manipulated data to ``self.data`` that is used in the module util apps.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
==============================================
2+
Contributing Modules to the Ansible Collection
3+
==============================================
4+
5+
.. toctree::
6+
:maxdepth: 4
7+
8+
Module Architecture <architecture>
9+
Creating a New Ansible Module <new_module>
10+
Adding New Module Options <update_module>
Loading
Loading

0 commit comments

Comments
 (0)