|
20 | 20 |
|
21 | 21 |
|
22 | 22 | 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 | +) |
23 | 31 |
|
24 | 32 |
|
25 | 33 | def convert_filename_to_version(filename): |
@@ -60,6 +68,34 @@ def __init__(self, host, username, password, secret="", port=22, **kwargs): |
60 | 68 | self._connected = False |
61 | 69 | self.open() |
62 | 70 |
|
| 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 | + |
63 | 99 | def _enter_config(self): |
64 | 100 | """Enter into config mode.""" |
65 | 101 | self.enable() |
@@ -155,6 +191,59 @@ def _uptime_components(self): |
155 | 191 |
|
156 | 192 | return days, hours, minutes |
157 | 193 |
|
| 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 | + |
158 | 247 | def _wait_for_device_reboot(self, timeout=3600): |
159 | 248 | """ |
160 | 249 | Wait for the device to finish reboot process and become accessible. |
@@ -184,6 +273,74 @@ def _wait_for_device_reboot(self, timeout=3600): |
184 | 273 | # TODO: Get proper hostname parameter |
185 | 274 | raise RebootTimeoutError(hostname=self.host, wait_time=timeout) |
186 | 275 |
|
| 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 | + |
187 | 344 | def backup_running_config(self, filename): |
188 | 345 | raise NotImplementedError |
189 | 346 |
|
@@ -313,7 +470,16 @@ def enable(self): |
313 | 470 | def facts(self): |
314 | 471 | raise NotImplementedError |
315 | 472 |
|
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 | + ): |
317 | 483 | """ |
318 | 484 | Copy a file from server to Controller. |
319 | 485 |
|
@@ -627,7 +793,8 @@ def set_boot_options(self, image_name, **vendor_specifics): |
627 | 793 | self.save() |
628 | 794 | if not self.boot_options["sys"] == image_name: |
629 | 795 | 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", |
631 | 798 | ) |
632 | 799 |
|
633 | 800 | def show(self, command, expect=False, expect_string=""): |
@@ -701,6 +868,78 @@ def show_list(self, commands): |
701 | 868 | def startup_config(self): |
702 | 869 | raise NotImplementedError |
703 | 870 |
|
| 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 | + |
704 | 943 | @property |
705 | 944 | def uptime(self): |
706 | 945 | """ |
|
0 commit comments