Skip to content

Commit a32a19b

Browse files
authored
Merge pull request #148 from networktocode/develop
Release 0.0.12
2 parents 65cd407 + a1c5604 commit a32a19b

29 files changed

+1112
-438
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/)
1313
### Security
1414

1515

16+
## [0.0.12]
17+
### Added
18+
- AIREOSDevice methods for pre-downloading images to Access Points (``transfer_image_to_ap``)
19+
### Changed
20+
- EOSDevice ``file_copy`` now uses Netmiko instead of custom code
21+
- Code format was updated with new `black` release
22+
1623
## [0.0.11]
1724

1825
### Added

poetry.lock

Lines changed: 163 additions & 164 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyntc/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
except ImportError:
1313
from ConfigParser import SafeConfigParser
1414

15-
__version__ = "0.0.11"
15+
__version__ = "0.0.12"
1616

1717
LIB_PATH_ENV_VAR = "PYNTC_CONF"
1818
LIB_PATH_DEFAULT = "~/.ntc.conf"

pyntc/devices/aireos_device.py

Lines changed: 241 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020

2121

2222
RE_FILENAME_FIND_VERSION = re.compile(r"^\S+?-[A-Za-z]{2}\d+-(?:\S+-?)?(?:K9-)?(?P<version>\d+-\d+-\d+-\d+)", re.M)
23+
RE_AP_IMAGE_COUNT = re.compile(r"^[Tt]otal\s+number\s+of\s+APs\.+\s+(?P<count>\d+)\s*$", re.M)
24+
RE_AP_IMAGE_DOWNLOADED = re.compile(r"^\s*[Cc]ompleted\s+[Pp]redownloading\.+\s+(?P<downloaded>\d+)\s*$", re.M)
25+
RE_AP_IMAGE_UNSUPPORTED = re.compile(r"^\s*[Nn]ot\s+[Ss]upported\.+\s+(?P<unsupported>\d+)\s*$", re.M)
26+
RE_AP_IMAGE_FAILED = re.compile(r"^\s*[Ff]ailed\s+to\s+[Pp]redownload\.+\s+(?P<failed>\d+)\s*$", re.M)
27+
RE_AP_BOOT_OPTIONS = re.compile(
28+
r"^(?P<name>.+?)\s+(?P<primary>(?:\d+\.){3}\d+)\s+(?P<backup>(?:\d+\.){3}\d+)\s+(?P<status>\S+).+$",
29+
re.M,
30+
)
2331

2432

2533
def convert_filename_to_version(filename):
@@ -60,6 +68,34 @@ def __init__(self, host, username, password, secret="", port=22, **kwargs):
6068
self._connected = False
6169
self.open()
6270

71+
def _ap_images_match_expected(self, image_option, image, ap_boot_options=None):
72+
"""
73+
Test that all AP images have the ``image_option`` matching ``image``.
74+
75+
Args:
76+
image_option (str): The boot_option dict key ("primary", "backup") to validate.
77+
image (str): The image that the ``image_option`` should match.
78+
ap_boot_options (dict): The results from
79+
80+
Returns:
81+
bool: True if all APs have ``image_option`` equal to ``image``, else False.
82+
83+
Example:
84+
>>> device = AIREOSDevice(**connection_args)
85+
>>> device.ap_boot_options()
86+
{
87+
'ap1': {'primary': {'8.10.105.0', 'secondary': '8.10.103.0'},
88+
'ap2': {'primary': {'8.10.105.0', 'secondary': '8.10.103.0'},
89+
}
90+
>>> device._ap_images_match_expected("primary", "8.10.105.0")
91+
True
92+
>>>
93+
"""
94+
if ap_boot_options is None:
95+
ap_boot_options = self.ap_boot_options
96+
97+
return all([boot_option[image_option] == image for boot_option in ap_boot_options.values()])
98+
6399
def _enter_config(self):
64100
"""Enter into config mode."""
65101
self.enable()
@@ -155,6 +191,59 @@ def _uptime_components(self):
155191

156192
return days, hours, minutes
157193

194+
def _wait_for_ap_image_download(self, timeout=3600):
195+
"""
196+
Wait for all APs have completed downloading the image.
197+
198+
Args:
199+
timeout (int): The max time to wait for all APs to download the image.
200+
201+
Raises:
202+
FileTransferError: When an AP is unable to properly retrieve the image or ``timeout`` is reached.
203+
204+
Example:
205+
>>> device = AIREOSDevice(**connection_args)
206+
>>> device.ap_image_stats()
207+
{
208+
"count": 2,
209+
"downloaded": 0,
210+
"unsupported": 0,
211+
"failed": 0,
212+
}
213+
>>> device._wait_for_ap_image_download()
214+
>>> device.ap_image_stats()
215+
{
216+
"count": 2,
217+
"downloaded": 2,
218+
"unsupported": 0,
219+
"failed": 0,
220+
}
221+
>>>
222+
223+
TODO:
224+
Change timeout to be a multiplier for number of APs attached to controller
225+
"""
226+
start = time.time()
227+
ap_image_stats = self.ap_image_stats
228+
ap_count = ap_image_stats["count"]
229+
downloaded = 0
230+
while downloaded < ap_count:
231+
ap_image_stats = self.ap_image_stats
232+
downloaded = ap_image_stats["downloaded"]
233+
unsupported = ap_image_stats["unsupported"]
234+
failed = ap_image_stats["failed"]
235+
# TODO: When adding logging, send log message of current stats
236+
if unsupported or failed:
237+
raise FileTransferError(
238+
"Failed transferring image to AP\n" f"Unsupported: {unsupported}\n" f"Failed: {failed}\n"
239+
)
240+
elapsed_time = time.time() - start
241+
if elapsed_time > timeout:
242+
raise FileTransferError(
243+
"Failed waiting for AP image to be transferred to all devices:\n"
244+
f"Total: {ap_count}\nDownloaded: {downloaded}"
245+
)
246+
158247
def _wait_for_device_reboot(self, timeout=3600):
159248
"""
160249
Wait for the device to finish reboot process and become accessible.
@@ -184,6 +273,74 @@ def _wait_for_device_reboot(self, timeout=3600):
184273
# TODO: Get proper hostname parameter
185274
raise RebootTimeoutError(hostname=self.host, wait_time=timeout)
186275

276+
@property
277+
def ap_boot_options(self):
278+
"""
279+
Boot Options for all APs associated with the controller.
280+
281+
Returns:
282+
dict: The name of each AP are the keys, and the values are the primary and backup values.
283+
284+
Example:
285+
>>> device = AIREOSDevice(**connection_args)
286+
>>> device.ap_boot_options
287+
{
288+
'ap1': {
289+
'backup': '8.8.125.0',
290+
'primary': '8.9.110.0',
291+
'status': 'complete'
292+
},
293+
'ap2': {
294+
'backup': '8.8.125.0',
295+
'primary': '8.9.110.0',
296+
'status': 'complete'
297+
},
298+
}
299+
>>>
300+
"""
301+
ap_images = self.show("show ap image all")
302+
ap_boot_options = RE_AP_BOOT_OPTIONS.finditer(ap_images)
303+
boot_options_by_ap = {
304+
ap["name"]: {
305+
"primary": ap.group("primary"),
306+
"backup": ap.group("backup"),
307+
"status": ap.group("status").lower(),
308+
}
309+
for ap in ap_boot_options
310+
}
311+
return boot_options_by_ap
312+
313+
@property
314+
def ap_image_stats(self):
315+
"""
316+
The stats of downloading the the image to all APs.
317+
318+
Returns:
319+
dict: The AP count, and the downloaded, unsupported, and failed APs.
320+
321+
Example:
322+
>>> device = AIREOSDevice(**connection_args)
323+
>>> device.ap_image_stats
324+
{
325+
'count': 2,
326+
'downloaded': 2,
327+
'unsupported': 0,
328+
'failed': 0
329+
}
330+
>>>
331+
"""
332+
ap_images = self.show("show ap image all")
333+
count = RE_AP_IMAGE_COUNT.search(ap_images).group(1)
334+
downloaded = RE_AP_IMAGE_DOWNLOADED.search(ap_images).group(1)
335+
unsupported = RE_AP_IMAGE_UNSUPPORTED.search(ap_images).group(1)
336+
failed = RE_AP_IMAGE_FAILED.search(ap_images).group(1)
337+
return {
338+
"count": int(count),
339+
"downloaded": int(downloaded),
340+
"unsupported": int(unsupported),
341+
"failed": int(failed),
342+
}
343+
187344
def backup_running_config(self, filename):
188345
raise NotImplementedError
189346

@@ -313,7 +470,16 @@ def enable(self):
313470
def facts(self):
314471
raise NotImplementedError
315472

316-
def file_copy(self, username, password, server, filepath, protocol="sftp", filetype="code", delay_factor=3):
473+
def file_copy(
474+
self,
475+
username,
476+
password,
477+
server,
478+
filepath,
479+
protocol="sftp",
480+
filetype="code",
481+
delay_factor=3,
482+
):
317483
"""
318484
Copy a file from server to Controller.
319485
@@ -627,7 +793,8 @@ def set_boot_options(self, image_name, **vendor_specifics):
627793
self.save()
628794
if not self.boot_options["sys"] == image_name:
629795
raise CommandError(
630-
command=boot_command, message="Setting boot command did not yield expected results",
796+
command=boot_command,
797+
message="Setting boot command did not yield expected results",
631798
)
632799

633800
def show(self, command, expect=False, expect_string=""):
@@ -701,6 +868,78 @@ def show_list(self, commands):
701868
def startup_config(self):
702869
raise NotImplementedError
703870

871+
def transfer_image_to_ap(self, image, timeout=None):
872+
"""
873+
Transfer ``image`` file to all APs connected to the WLC.
874+
875+
Args:
876+
image (str): The image that should be sent to the APs.
877+
timeout (int): The max time to wait for all APs to download the image.
878+
879+
Returns:
880+
bool: True if AP images are transferred or swapped, False otherwise.
881+
882+
Example:
883+
>>> device = AIREOSDevice(**connection_args)
884+
>>> device.ap_boot_options
885+
{
886+
'ap1': {
887+
'backup': '8.8.125.0',
888+
'primary': '8.9.110.0',
889+
'status': 'complete'
890+
},
891+
'ap2': {
892+
'backup': '8.8.125.0',
893+
'primary': '8.9.110.0',
894+
'status': 'complete'
895+
},
896+
}
897+
>>> device.transfer_image_to_ap("8.10.1.0")
898+
>>> device.ap_boot_options
899+
{
900+
'ap1': {
901+
'backup': '8.9.110.0',
902+
'primary': '8.10.1.0',
903+
'status': 'complete'
904+
},
905+
'ap2': {
906+
'backup': '8.9.110.0',
907+
'primary': '8.10.1.0',
908+
'status': 'complete'
909+
},
910+
}
911+
>>>
912+
"""
913+
boot_options = ["primary", "backup"]
914+
ap_boot_options = self.ap_boot_options
915+
changed = False
916+
if self._ap_images_match_expected("primary", image, ap_boot_options):
917+
return changed
918+
919+
if not any(self._ap_images_match_expected(option, image, ap_boot_options) for option in boot_options):
920+
changed = True
921+
download_image = None
922+
for option in boot_options:
923+
if self.boot_options[option] == image:
924+
download_image = option
925+
break
926+
if download_image is None:
927+
raise FileTransferError(f"Unable to find {image} on {self.host}")
928+
929+
self.config(f"ap image predownload {option} all")
930+
self._wait_for_ap_image_download()
931+
932+
if self._ap_images_match_expected("backup", image):
933+
changed = True
934+
self.config("ap image swap all")
935+
# testing showed delay in reflecting changes when issuing `show ap image all`
936+
time.sleep(1)
937+
938+
if not self._ap_images_match_expected("primary", image):
939+
raise FileTransferError(f"Unable to set all APs to use {image}")
940+
941+
return changed
942+
704943
@property
705944
def uptime(self):
706945
"""

pyntc/devices/base_device.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,7 @@ def checkpoint(self, filename):
7979

8080
@abc.abstractmethod
8181
def close(self):
82-
"""Close the connection to the device.
83-
"""
82+
"""Close the connection to the device."""
8483
raise NotImplementedError
8584

8685
@abc.abstractmethod
@@ -230,8 +229,7 @@ def install_os(self, image_name, **vendor_specifics):
230229

231230
@abc.abstractmethod
232231
def open(self):
233-
"""Open a connection to the device.
234-
"""
232+
"""Open a connection to the device."""
235233
raise NotImplementedError
236234

237235
@abc.abstractmethod
@@ -256,8 +254,7 @@ def rollback(self, checkpoint_file):
256254
@property
257255
@abc.abstractmethod
258256
def running_config(self):
259-
"""Return the running configuration of the device.
260-
"""
257+
"""Return the running configuration of the device."""
261258
raise NotImplementedError
262259

263260
@abc.abstractmethod
@@ -326,8 +323,7 @@ def show_list(self, commands, raw_text=False):
326323
@property
327324
@abc.abstractmethod
328325
def startup_config(self):
329-
"""Return the startup configuration of the device.
330-
"""
326+
"""Return the startup configuration of the device."""
331327
raise NotImplementedError
332328

333329
#################################
@@ -336,8 +332,7 @@ def startup_config(self):
336332

337333
def feature(self, feature_name):
338334
"""Return a feature class based on the ``feature_name`` for the
339-
appropriate subclassed device type.
340-
"""
335+
appropriate subclassed device type."""
341336
try:
342337
feature_module = importlib.import_module(
343338
"pyntc.devices.system_features.%s.%s_%s" % (feature_name, self.device_type, feature_name)
@@ -359,13 +354,11 @@ def get_boot_options(self):
359354
return self.boot_options
360355

361356
def refresh(self):
362-
"""Refresh caches on device instance.
363-
"""
357+
"""Refresh caches on device instance."""
364358
self.refresh_facts()
365359

366360
def refresh_facts(self):
367-
"""Refresh cached facts.
368-
"""
361+
"""Refresh cached facts."""
369362
# Persist values that were not added by facts getter
370363
if isinstance(self._facts, dict):
371364
facts_backup = self._facts.copy()

0 commit comments

Comments
 (0)