Page MenuHomePhabricator

gNMI module in Spicerack
Open, HighPublic

Description

Following the doc from https://wikitech.wikimedia.org/wiki/Spicerack#Adding_new_module_or_change_in_core_behaviour

A problem statement
We're going to use gNMI more and more to communicate with Network devices (see. Blog Post: Multi-platform network configuration).
As we use (and are going to use more) cookbooks to interact with network devices (get data and push config), we need an easy to use and standardized abstraction layer to use gNMI.

Example of such functions are in the PoC https://gerrit.wikimedia.org/r/c/operations/cookbooks/+/924896 and marked with # Move to spicerack module as using cookbooks/sre/network/__init__.py doesn't scale and doesn't benefit from the scrutiny of a Spicerack module.
They're of course not set in stone and would need to be adapted to work as a Spicerack module.

Current proxy support for local dev implemented in https://github.com/akarneliuk/pygnmi/pull/133/

Third party dependencies

Additional configuration
The current example uses this config file:

.config/gnmi/config.yaml
---
username: ayounsi
password: Wikimedia

With the current pending patches, local testing can be done (after the usual spicerack/cookbook dev setup) with:
Setup the proxy:
ssh cumin1002.eqiad.wmnet -D 8888

$ cat tiniproxy.conf
Port 8080
Listen 127.0.0.1
Timeout 600
Allow 127.0.0.1
Upstream socks5 localhost:8888

tinyproxy -d -c tiniproxy.conf

grpc_proxy=http://localhost:8080 cookbook sre.network.example-gnmi-cookbook sretest1004

Event Timeline

Restricted Application added a subscriber: Aklapper. · View Herald Transcript

Change #1015334 had a related patch set uploaded (by Ayounsi; author: Ayounsi):

[operations/software/spicerack@master] Spicerack module for gNMI

https://gerrit.wikimedia.org/r/1015334

Change #1015335 had a related patch set uploaded (by Ayounsi; author: Ayounsi):

[operations/cookbooks@master] Example cookbook using gNMI module

https://gerrit.wikimedia.org/r/1015335

For the record I looked deeper at gNMI to configure Juniper devices.

Some of the findings: current code fails with a reply from the switch about a missing configure; statement. But unfortunately I couldn't find any doc about it.
My understanding so far is that we have to pick between a Juniper yang model for interfaces or the openconfig-interfaces one. But we can't do both at once. For example by default, querying an openconfig path returns only an error about not existing. More testing and investigation to do if we go in that direction.

1diff --git cookbooks/sre/network/example-gnmi-cookbook.py cookbooks/sre/network/example-gnmi-cookbook.py
2index 075b913..026b53e 100644
3--- cookbooks/sre/network/example-gnmi-cookbook.py
4+++ cookbooks/sre/network/example-gnmi-cookbook.py
5@@ -37,7 +37,7 @@ class ExampleGnmiCookbookRunner(CookbookRunnerBase):
6 self.verbose = spicerack.verbose
7 self.netbox_server = spicerack.netbox_server(args.host)
8 self.netbox_data = self.netbox_server.as_dict()
9- self.switch = spicerack.gnmi(device_fqdn=self.netbox_server.switch_fqdn)
10+ self.switch = spicerack.gnmi(device_fqdn=self.netbox_server.switch_fqdn, port=32767)
11
12 if self.netbox_data["is_virtual"]:
13 logger.error("This cookbook is intended for baremetal hosts only")
14@@ -49,7 +49,7 @@ class ExampleGnmiCookbookRunner(CookbookRunnerBase):
15
16 def run(self):
17 """Required by Spicerack API."""
18- # print(self.switch.gnmi_get('openconfig-interfaces:interfaces/interface[name=Ethernet0]/config'))
19+ print(self.switch.gnmi_get('juniper:interfaces'))
20
21 new_config = self.netbox_server.switch_interface_config
22 pending_change = False
23@@ -61,13 +61,15 @@ class ExampleGnmiCookbookRunner(CookbookRunnerBase):
24 if not pending_change:
25 logger.info("Nothing to do.")
26 return
27- print(new_config[1][0])
28 ask_confirmation(f"Push the above changes to {self.switch.device_fqdn} ?")
29- # Delete and replace not allowed for Physical interfaces on SONiC
30- # The delete vlan config is workaround for a SONiC bug not removing config when needed on replace
31- action = {
32- "delete": [new_config[1][0]],
33- "replace": [new_config[1]], # Set the new vlan config
34- "update": [new_config[0]], # Update interface config
35- }
36- self.switch.gnmi_set(action)
37+ if len(new_config) > 1:
38+ # Delete and replace not allowed for Physical interfaces on SONiC
39+ # The delete vlan config is workaround for a SONiC bug not removing config when needed on replace
40+ action = {
41+ "delete": [new_config[1][0]],
42+ "replace": [new_config[1]], # Set the new vlan config
43+ "update": [new_config[0]], # Update interface config
44+ }
45+ else:
46+ action = {"replace": [new_config[0]]}
47+ self.switch.gnmi_set(action)
48\ No newline at end of file

1diff --git spicerack/__init__.py spicerack/__init__.py
2index 3ff1d0e..e571cb9 100644
3--- spicerack/__init__.py
4+++ spicerack/__init__.py
5@@ -755,13 +755,14 @@ class Spicerack: # pylint: disable=too-many-instance-attributes
6 """
7 return AptGetHosts(remote_hosts)
8
9- def gnmi(self, *, device_fqdn: str) -> GnmiBase:
10+ def gnmi(self, *, device_fqdn: str, port: int = 8080) -> GnmiBase:
11 """Get a gNMI instance to interact with a network device."""
12 config = load_yaml_config(self._spicerack_config_dir / "gnmi" / "config.yaml")
13 return GnmiBase(
14 device_fqdn=device_fqdn,
15 username=config["username"],
16 password=config["password"],
17+ port=port,
18 dry_run=self._dry_run,
19 verbose=self._verbose,
20 )
21diff --git spicerack/gnmi.py spicerack/gnmi.py
22index 2d094ff..6e1ef23 100644
23--- spicerack/gnmi.py
24+++ spicerack/gnmi.py
25@@ -17,7 +17,7 @@ class GnmiBase:
26 """Class which wraps gNMI operations."""
27
28 def __init__(
29- self, device_fqdn: str, username: str, password: str, dry_run: bool = False, verbose: bool = False
30+ self, device_fqdn: str, username: str, password: str, port: int = 8080, dry_run: bool = False, verbose: bool = False
31 ) -> None:
32 """Create gNMI instance.
33
34@@ -32,7 +32,7 @@ class GnmiBase:
35 self._dry_run = dry_run
36 self._verbose = verbose
37 self._device_fqdn = device_fqdn
38- target = (device_fqdn, "8080")
39+ target = (device_fqdn, port)
40 show_diff = "print" if verbose else ""
41 self._client = gNMIclient(
42 target=target, username=username, password=password, debug=verbose, show_diff=show_diff
43@@ -87,8 +87,8 @@ class GnmiBase:
44 # a client desiring a set of changes to be applied together
45 # MUST ensure that they are encapsulated within a single SetRequest message.
46 change_pending = False
47- for action in ("delete", "replace", "update"):
48- if actions.get(action, None):
49+ for operation in ("delete", "replace", "update"):
50+ if actions.get(operation, None):
51 change_pending = True
52 if not change_pending:
53 logger.info("No changes to push to the device.")
54diff --git spicerack/netbox.py spicerack/netbox.py
55index 47eaa2b..4945a1e 100644
56--- spicerack/netbox.py
57+++ spicerack/netbox.py
58@@ -383,9 +383,18 @@ class NetboxServer:
59 if self.virtual:
60 raise NetboxError("Server is a virtual machine, can't return a switch interface.")
61 primary_ip = self._server.primary_ip
62- if not primary_ip:
63- raise NetboxError("No primary IP, needed to find the primary interface.")
64- netbox_iface = primary_ip.assigned_object
65+ # First through the primary IP (eg. host is live)
66+ # secondly thtough the connected interfaced (eg. )
67+ if primary_ip:
68+ netbox_iface = primary_ip.assigned_object
69+ else:
70+ # Workaround for Netbox REST API not having a __isnull filter (eg. cable__isnull=False)
71+ netbox_iface = None
72+ for iface in self._api.dcim.interfaces.filter(device=self._server.name, mgmt_only=False):
73+ if iface.cable:
74+ if netbox_iface:
75+ raise NetboxError("More than 1 potential primary interface.")
76+ netbox_iface = iface
77 if not netbox_iface:
78 raise NetboxError("Primary IP not assigned to an interface.")
79 netbox_switch_iface = netbox_iface.connected_endpoint
80@@ -440,21 +449,32 @@ class NetboxServer:
81 "Updated Netbox switchport access vlan from %s to %s for device %s", current, value, self._server.name
82 )
83
84- @property
85- def fqdn(self) -> str:
86- """Return the FQDN of the device.
87+ def _fqdn_finder(self, device) -> str:
88+ """Return the primary FQDN of a device.
89
90 Raises:
91- spicerack.netbox.NetboxError: if the server has no FQDN defined in Netbox.
92+ spicerack.netbox.NetboxError: if the device has no FQDN defined in Netbox.
93
94 """
95- # Until https://phabricator.wikimedia.org/T253173 is fixed we can't use the primary_ip attribute
96+ # Workaround for a bug where address.dns_name returns
97+ # AttributeError: type object 'IpAddresses' has no attribute 'dns_name'
98+ device.full_details()
99+ # Until https://phabricator.wikimedia.org/T253173 is fixed we can't use the primary_ip attribute
100 for attr_name in ("primary_ip4", "primary_ip6"):
101- address = getattr(self._server, attr_name)
102+ address = getattr(device, attr_name)
103 if address is not None and address.dns_name:
104 return address.dns_name
105+ raise NetboxError(f"Device {device.name} does not have any primary IP with a DNS name set.")
106
107- raise NetboxError(f"Server {self._server.name} does not have any primary IP with a DNS name set.")
108+ @property
109+ def fqdn(self) -> str:
110+ """Return the FQDN of the device.
111+
112+ Raises:
113+ spicerack.netbox.NetboxError: if the server has no FQDN defined in Netbox.
114+
115+ """
116+ return self._fqdn_finder(self._server)
117
118 @property
119 def switch_fqdn(self) -> str:
120@@ -464,15 +484,7 @@ class NetboxServer:
121 spicerack.netbox.NetboxError: if the switch has no FQDN defined in Netbox.
122
123 """
124- netbox_switch_iface = self._find_primary_switch_iface()
125- netbox_switch = netbox_switch_iface.device
126- # Until https://phabricator.wikimedia.org/T253173 is fixed we can't use the primary_ip attribute
127- for attr_name in ("primary_ip4", "primary_ip6"):
128- address = getattr(netbox_switch, attr_name)
129- if address is not None and address.dns_name:
130- return address.dns_name
131-
132- raise NetboxError(f"Switch {netbox_switch.name} does not have any primary IP with a DNS name set.")
133+ return self._fqdn_finder(self._find_primary_switch_iface().device)
134
135 @property
136 def mgmt_fqdn(self) -> str:
137@@ -511,23 +523,33 @@ class NetboxServer:
138
139 @property
140 def switch_interface_config(self) -> list:
141- """Return the server's switch side config in an openconfig-interfaces format.
142+ """Return the server's switch side config in an openconfig-interfaces or Juniper format.
143
144 Raises:
145 spicerack.netbox.NetboxError: for virtual servers or the server has no management FQDN defined in Netbox.
146
147 """
148 nb_switch_interface = self._find_primary_switch_iface()
149- interface_path = f"openconfig-interfaces:interfaces/interface[name={nb_switch_interface}]"
150- interface_config_path = f"{interface_path}/config"
151- vlan_config_path = f"{interface_path}/openconfig-if-ethernet:ethernet/openconfig-vlan:switched-vlan/config"
152- # MTU: SONiC bug, `no mtu` sets it to 9100 in the API, to be adapted when/if we add Juniper support
153- interface_config = {
154- "enabled": nb_switch_interface.enabled,
155- "mtu": nb_switch_interface.mtu if nb_switch_interface.mtu else 9100,
156- "name": str(nb_switch_interface),
157- "type": "iana-if-type:ethernetCsmacd", # Default, to prevent it showing up in diff
158- }
159+ nb_switch_interface.device.full_details()
160+ vendor = nb_switch_interface.device.device_type.manufacturer.slug
161+ if vendor == 'juniper':
162+ interface_path = f"juniper:interfaces/interface[name={nb_switch_interface}]"
163+ interface_config = {"name": str(nb_switch_interface)}
164+ if not nb_switch_interface.enabled:
165+ interface_config['disable'] = [None]
166+ if nb_switch_interface.mtu:
167+ interface_config['mtu'] = nb_switch_interface.mtu
168+ else:
169+ interface_path = f"openconfig-interfaces:interfaces/interface[name={nb_switch_interface}]"
170+ interface_config_path = f"{interface_path}/config"
171+ vlan_config_path = f"{interface_path}/openconfig-if-ethernet:ethernet/openconfig-vlan:switched-vlan/config"
172+ # MTU: SONiC bug, `no mtu` sets it to 9100 in the API, to be adapted when/if we add Juniper support
173+ interface_config = {
174+ "enabled": nb_switch_interface.enabled,
175+ "mtu": nb_switch_interface.mtu if nb_switch_interface.mtu else 9100,
176+ "name": str(nb_switch_interface),
177+ "type": "iana-if-type:ethernetCsmacd", # Default, to prevent it showing up in diff
178+ }
179 vlan_config = {}
180 if not nb_switch_interface.enabled:
181 interface_config["description"] = "DISABLED"
182@@ -539,17 +561,32 @@ class NetboxServer:
183 # VLAN
184 if not nb_switch_interface.mode:
185 raise NetboxError(f"Switch interface for server {self._server.name} with no vlan configured.")
186- # Interface mode
187- vlan_config["interface-mode"] = "ACCESS" if nb_switch_interface.mode.value == "access" else "TRUNK"
188- # Native vlan
189- if nb_switch_interface.untagged_vlan:
190- vlan_config["access-vlan"] = nb_switch_interface.untagged_vlan.vid
191- vlan_config["trunk-vlans"] = [tagged_vlan.vid for tagged_vlan in nb_switch_interface.tagged_vlans]
192-
193- return [
194- (interface_config_path, {"openconfig-interfaces:config": interface_config}),
195- (vlan_config_path, {"openconfig-vlan:config": vlan_config} if vlan_config else {}),
196- ]
197+ if vendor == 'juniper':
198+ vlan_config = {'vlan': {'members': []}}
199+ if nb_switch_interface.mode.value == "access":
200+ vlan_config["interface-mode"] = "access"
201+ vlan_config["vlan"]['members'] = [nb_switch_interface.untagged_vlan.name]
202+ else:
203+ vlan_config["interface-mode"] = "trunk"
204+ vlan_config["vlan"]['members'].extend([tagged_vlan.name for tagged_vlan in nb_switch_interface.tagged_vlans])
205+ if nb_switch_interface.untagged_vlan:
206+ vlan_config["native-vlan-id"] = nb_switch_interface.untagged_vlan.vid
207+ interface_config['unit'] = [{'name': 0, 'family': {'ethernet-switching': vlan_config}}]
208+ else:
209+ # Interface mode
210+ vlan_config["interface-mode"] = "ACCESS" if nb_switch_interface.mode.value == "access" else "TRUNK"
211+ # Native vlan
212+ if nb_switch_interface.untagged_vlan:
213+ vlan_config["access-vlan"] = nb_switch_interface.untagged_vlan.vid
214+ vlan_config["trunk-vlans"] = [tagged_vlan.vid for tagged_vlan in nb_switch_interface.tagged_vlans]
215+
216+ if vendor == 'juniper':
217+ return [(interface_path, interface_config)]
218+ else:
219+ return [
220+ (interface_config_path, {"openconfig-interfaces:config": interface_config}),
221+ (vlan_config_path, {"openconfig-vlan:config": vlan_config} if vlan_config else {}),
222+ ]
223
224 def as_dict(self) -> dict:
225 """Return a dict containing details about the server."""