PyPANRestV2 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pypanrestv2/Base.py ADDED
@@ -0,0 +1,1772 @@
1
+ import getpass
2
+ from typing import Optional, Dict, Any, Tuple, Union, List, Protocol, Set, TypeVar
3
+ from . import ApplicationHelper
4
+ from . import Exceptions
5
+ import pycountry
6
+ import ipaddress
7
+ import builtins
8
+ import time
9
+ import re
10
+ from datetime import datetime
11
+ from icecream import ic
12
+ import sys
13
+ import xmltodict
14
+ import dns.resolver
15
+ import requests
16
+ from requests.packages.urllib3.exceptions import InsecureRequestWarning
17
+ import logging
18
+ import xml.etree.ElementTree as ET
19
+ from icecream import ic
20
+
21
+ logger = logging.getLogger(__name__)
22
+ requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
23
+
24
+ class NetworkRequestError(Exception):
25
+ """
26
+ Exception raised for errors in the network request.
27
+
28
+ Attributes:
29
+ message -- explanation of the error
30
+ details -- optional details of the error (default None)
31
+ """
32
+ def __init__(self, message, details=None):
33
+ super().__init__(message)
34
+ self.details = details
35
+
36
+ class PAN:
37
+ """
38
+ Base class for palo alto devices
39
+ The IP address of the device is the only mandatory parameter.
40
+ Either api_key or username and password are needed. The API_KEY can be obtained from using the username and password
41
+ to connect to the device.
42
+ The ver for software version is required for API calls. If this is not provided, it can be obtained by connecting
43
+ to the devide and issuing the OP command "show system info".
44
+ The "show system info" also returns a number of other usefull information that can be used by the class so might
45
+ as well extract it at the same time.
46
+ Since access to the device can be restricted to prevent execuing OP commands, there needs to be another REST call
47
+ that can be made to verify the API_KEY. This can be prompted for, or provided via config.yaml input. Without the
48
+ "show system info", the version must be provided. If the sytem is not able to get the version, raise an exception.
49
+ """
50
+ yes_no = ['yes', 'no']
51
+
52
+ def __init__(self, base_url: str, **kwargs):
53
+ self.base_url: str = base_url
54
+ self.api_key: str = kwargs.get('api_key')
55
+ self.username: str = kwargs.get('username')
56
+ self.password: str = kwargs.get('password')
57
+ self.force_interactive: bool = kwargs.get('force_interactive', True)
58
+ self.session: requests.Session = requests.Session()
59
+ self.session.verify = False
60
+ if self.api_key:
61
+ try:
62
+ self.session.headers = {'X-PAN-KEY': self.api_key}
63
+ self.SystemInfo: dict = self.op('show system info').get('result', {}).get('system')
64
+ except requests.HTTPError as http_err:
65
+ logger.error(f'HTTP error occurred: {http_err}')
66
+ elif self.username and self.password:
67
+ try:
68
+ self.api_key = self.getkey()
69
+ self.session.headers = {'X-PAN-KEY': self.api_key}
70
+ self.SystemInfo: dict = self.op('show system info').get('result', {}).get('system')
71
+ except requests.HTTPError as http_err:
72
+ logger.error(f'HTTP error occurred: {http_err}')
73
+ else:
74
+ raise ValueError("Must provide either an api_key or both username and password for authentication.")
75
+
76
+ if self.SystemInfo:
77
+ self.ver = self.ver_from_sw_version(self.SystemInfo['sw-version'])
78
+ if self.password:
79
+ # if we used a password, clear it from memory as it is no longer needed now that we have a working API key
80
+ self.password = None
81
+ self.hostname = self.SystemInfo['hostname']
82
+ else:
83
+ raise ValueError(f"Can not determine the PANOS version for {self.base_url}.")
84
+
85
+ @staticmethod
86
+ def valid_name(value: str, max_name_length: int) -> bool:
87
+ """
88
+ Validates if the provided string is a valid name according to the allowed character set and length.
89
+
90
+ Parameters:
91
+ value (str): The string to validate.
92
+ max_name_length (int): The maximum length of the name to be validated
93
+
94
+ Returns:
95
+ bool: True if the string is valid, otherwise raises a ValueError.
96
+
97
+ Raises:
98
+ ValueError: If the string contains invalid characters or exceeds the maximum length.
99
+
100
+ """
101
+
102
+ # Define the allowed character set using a regular expression.
103
+ # The pattern now ensures the entire string consists of the allowed characters.
104
+
105
+ pattern = r"^[ 0-9a-zA-Z._-]+$"
106
+
107
+ allowed = re.compile(pattern, re.IGNORECASE)
108
+
109
+ # Check length first for efficiency
110
+ if len(value) > max_name_length:
111
+ raise ValueError(f"The name exceeds the maximum length of {max_name_length} characters.")
112
+ if not allowed.match(value):
113
+ raise ValueError(
114
+ "The name contains invalid characters. Only alphanumeric characters, spaces, and ._- are allowed.")
115
+
116
+ return True
117
+
118
+ def rest_request(self, method: str, **kwargs) -> dict:
119
+ valid_endpoints = ['Objects', 'Policies', 'Network', 'Device', 'Panorama']
120
+ if self.endpoint not in valid_endpoints:
121
+ raise ValueError(f'endpoint attribute must be one of {valid_endpoints}, not {self.endpoint}.')
122
+ if self.endpoint == 'Policies':
123
+ if self.rulebase:
124
+ url = f"{self.base_url}/restapi/{self.ver}/{self.endpoint}/{self.__class__.__name__.split('Rules')[0]}{self.rulebase}Rules"
125
+ else:
126
+ url = f"{self.base_url}/restapi/{self.ver}/{self.endpoint}/{self.__class__.__name__}"
127
+ response = self.session.request(method, url, **kwargs)
128
+ response.raise_for_status()
129
+ return response.json()
130
+
131
+ def xml_request(self, **kwargs) -> dict:
132
+ """
133
+ xml requests are always GET
134
+ :param kwargs:
135
+ :return:
136
+ """
137
+ url = f"{self.base_url}/api/"
138
+ response = self.session.get(url, **kwargs)
139
+ response.raise_for_status()
140
+ return xmltodict.parse(response.text)
141
+
142
+ @staticmethod
143
+ def is_interactive_mode() -> bool:
144
+ """
145
+ Check if the script is running in an interactive mode.
146
+ """
147
+ return sys.stdin.isatty()
148
+
149
+ def prompt_for_credentials(self) -> None:
150
+ """
151
+ Prompt the user for credentials if not already set.
152
+ """
153
+ if not self.username:
154
+ self.username = input('Please enter the admin username: ')
155
+ if not self.password:
156
+ self.password = getpass.getpass('Please enter the admin password: ')
157
+
158
+ def getkey(self) -> Optional[str]:
159
+ """
160
+ Get the API key from a Palo Alto Networks firewall or Panorama.
161
+ :return: API key as a string or None if an error occurs
162
+ """
163
+ params: dict[str, str] = {
164
+ 'type': 'keygen',
165
+ 'user': self.username,
166
+ 'password': self.password
167
+ }
168
+ try:
169
+ response = self.xml_request(params=params)
170
+ except requests.exceptions.ConnectionError:
171
+ logger.error('Error connecting to firewall.')
172
+ return None
173
+
174
+ # Check for error code in the response
175
+ if response.get('response', {}).get('@code') == '403':
176
+ logger.warning('Access denied. Check your username and password.')
177
+ return None
178
+ else:
179
+ # Extract the API key from the parsed response
180
+ api_key: Optional[str] = response.get('response', {}).get('result', {}).get('key')
181
+ return api_key
182
+
183
+ @staticmethod
184
+ def ver_from_sw_version(sw_version: str) -> Optional[str]:
185
+ """
186
+ Extracts and formats the version string to include only the major and minor version numbers,
187
+ prefixed with 'v'. If the input string does not contain a version pattern that can be parsed,
188
+ None is returned.
189
+
190
+ :param sw_version: The software version string to parse.
191
+ :return: A formatted version string or None if parsing fails.
192
+ """
193
+ try:
194
+ # Split the string by decimal point and ensure at least two parts exist
195
+ parts = sw_version.split('.')
196
+ if len(parts) >= 2:
197
+ # Use only the first two parts (major and minor versions)
198
+ result = '.'.join(parts[:2])
199
+ return f'v{result}'
200
+ else:
201
+ return None
202
+ except AttributeError:
203
+ # In case the input is not a string or another error occurs
204
+ return None
205
+
206
+ @property
207
+ def base_url(self):
208
+ return self._base_url
209
+
210
+ @base_url.setter
211
+ def base_url(self, value: str):
212
+ # Normalize the value by stripping the scheme (if present) for validation
213
+ stripped_value = value.split("//")[-1]
214
+ # Split stripped value to isolate the hostname/IP and the rest of the path
215
+ hostname_and_port = stripped_value.split('/')[0]
216
+
217
+ # Extract hostname/IP and port (if present)
218
+ if ':' in hostname_and_port:
219
+ hostname, port = hostname_and_port.split(':', 1)
220
+ else:
221
+ hostname, port = hostname_and_port, None
222
+
223
+ # Validate port if it exists
224
+ if port:
225
+ if not port.isdigit() or not (1 <= int(port) <= 65535):
226
+ raise ValueError(f"Provided port {port} in base_url {value} is invalid.")
227
+
228
+ # Initialize a flag to indicate whether the provided hostname is an IP address
229
+ is_ip_address = False
230
+
231
+ # Check if the hostname is an IP address
232
+ try:
233
+ ipaddress.ip_address(hostname) # Validate as IP address
234
+ is_ip_address = True
235
+ except ValueError:
236
+ is_ip_address = False
237
+
238
+ # If not an IP address, attempt DNS resolution
239
+ if not is_ip_address:
240
+ try:
241
+ # Perform DNS resolution
242
+ dns.resolver.resolve(hostname, 'A')
243
+ except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
244
+ raise ValueError(f"Provided base_url {value} is neither a valid IP address nor a resolvable hostname.")
245
+
246
+ # Prepend https:// if missing
247
+ if not value.startswith('https://'):
248
+ value = 'https://' + value
249
+
250
+ # Assign the validated and normalized base_url to the instance attribute
251
+ self._base_url = value
252
+
253
+ @property
254
+ def hostname(self):
255
+ return self._hostname
256
+
257
+ @hostname.setter
258
+ def hostname(self, value):
259
+ if not isinstance(value, str):
260
+ raise ValueError("Hostname must be a string.")
261
+ if len(value) >= 32:
262
+ raise ValueError("Hostname must be less than 32 characters long.")
263
+ self._hostname = value
264
+
265
+ @staticmethod
266
+ def string_to_xml(string, **kwargs) -> str:
267
+ # Basic function to take a string and convert it to xml format for issuing Operational Commands.
268
+ # Sometimes a value is given, this value is a single string that is placed in the middle of the XML string.
269
+ value = kwargs.get('value')
270
+ data = string.split(' ')
271
+ XML_Right = []
272
+ XML_Left = []
273
+ # Build out the start and end of the XML string
274
+ for item in data:
275
+ left = f'<{item}>'
276
+ right = f'</{item}>'
277
+ XML_Left.append(left)
278
+ XML_Right.insert(0, right)
279
+ # Add in the value by putting it at the end of the left most XML tags.
280
+ if value:
281
+ XML_Left.append(value)
282
+ # Close off the XML by adding in the right most XML tags.
283
+ XML_Left.extend(XML_Right)
284
+ return ''.join(XML_Left)
285
+
286
+ def set_xml(self, xpath: str, element: str) -> dict:
287
+ """
288
+ Set a config setting using the XML API
289
+ :param xpath: The xpath of the element to be set
290
+ :param element: The element to be set
291
+ :return: Dict with the status of the set command
292
+ """
293
+ params: dict = {
294
+ 'type': 'config',
295
+ 'action': 'set',
296
+ 'xpath': xpath,
297
+ 'element': element
298
+ }
299
+ try:
300
+ parsed_response = self.xml_request(params=params, timeout=8)
301
+ status = parsed_response['response']['@status']
302
+ message = parsed_response['response'].get('msg', 'No message provided')
303
+
304
+ return {'status': status, 'msg': message}
305
+ except requests.exceptions.HTTPError as e:
306
+ logging.error(f"HTTP error occurred: {e}")
307
+ return {'status': 'error', 'msg': str(e)}
308
+ except requests.exceptions.ConnectionError as e:
309
+ logging.error(f"Connection error occurred: {e}")
310
+ return {'status': 'error', 'msg': 'Connection error'}
311
+ except requests.exceptions.Timeout as e:
312
+ logging.error(f"Request timed out: {e}")
313
+ return {'status': 'error', 'msg': 'Timeout'}
314
+ except Exception as e:
315
+ logging.error(f"An unexpected error occurred: {e}")
316
+ return {'status': 'error', 'msg': 'Unexpected error'}
317
+
318
+ def get_xml(self, xpath: str) -> Dict[str, Any]:
319
+ """
320
+ Fetches configuration from the device using an XML format via a GET request.
321
+
322
+ :param xpath: The XPath specifying the configuration to fetch.
323
+ :return: A dictionary parsed from the XML response.
324
+ """
325
+ params: Dict[str, str] = {
326
+ 'type': 'config',
327
+ 'action': 'get',
328
+ 'xpath': xpath,
329
+ }
330
+ try:
331
+ response = self.xml_request(params=params, timeout=8)
332
+ return response
333
+ except requests.exceptions.HTTPError as e:
334
+ logging.error(f'HTTP error occurred: {e}') # Specific HTTP errors (e.g., 404, 401, ...)
335
+ except requests.exceptions.ConnectionError as e:
336
+ logging.error(f'Error connecting to the server: {e}') # Problems with the network
337
+ except requests.exceptions.Timeout as e:
338
+ logging.error(f'Timeout error: {e}') # The request timed out
339
+ except requests.exceptions.RequestException as e:
340
+ logging.error(f'Error during requests to {url}: {e}') # Any other requests exception
341
+ except Exception as e:
342
+ logging.error(f'An unexpected error occurred while processing the XML: {e}') # Non-requests related errors
343
+
344
+ # Return an empty dict or a specific error structure if preferred
345
+ return {'error': 'An error occurred while fetching the XML configuration.'}
346
+
347
+ def edit_xml(self, xpath: str, element: str) -> Dict[str, Any]:
348
+ """
349
+ Edit XML configuration by specifying a path and the element to edit.
350
+
351
+ :param xpath: The XPath to the configuration element to edit.
352
+ :param element: The new XML element to insert.
353
+ :return: A dictionary with the status and message of the operation.
354
+ """
355
+ params = {
356
+ 'type': 'config',
357
+ 'action': 'edit',
358
+ 'xpath': xpath,
359
+ 'element': element
360
+ }
361
+ try:
362
+ response = self.xml_request(params=params, timeout=8)
363
+ return {
364
+ 'status': response['response'].get('@status'),
365
+ 'msg': response['response'].get('msg', 'No message provided')
366
+ }
367
+ except requests.exceptions.RequestException as e:
368
+ logger.error(f"Request error: {e}")
369
+ return {'status': 'error', 'msg': str(e)}
370
+ except Exception as e: # Generic exception handling
371
+ logger.error(f"Parsing error or other exception: {e}")
372
+ return {'status': 'error', 'msg': 'An unexpected error occurred'}
373
+
374
+ def delete_xml(self, xpath: str) -> Dict[str, Any]:
375
+ """
376
+ Delete an XML configuration element specified by the XPath.
377
+
378
+ :param xpath: The XPath to the configuration element to delete.
379
+ :return: A dictionary with the status and message of the operation.
380
+ """
381
+ params = {
382
+ 'type': 'config',
383
+ 'action': 'delete',
384
+ 'xpath': xpath,
385
+ }
386
+ try:
387
+ response = self.xml_request(params=params, timeout=8)
388
+
389
+ return {
390
+ 'status': response['response'].get('@status'),
391
+ 'msg': response['response'].get('msg', 'No message provided')
392
+ }
393
+ except requests.exceptions.RequestException as e:
394
+ logger.error(f"Request error: {e}")
395
+ return {'status': 'error', 'msg': str(e)}
396
+ except Exception as e: # Catching generic exceptions for logging and error reporting
397
+ logger.error(f"Parsing error or other exception: {e}")
398
+ return {'status': 'error', 'msg': 'An unexpected error occurred'}
399
+
400
+ def override(self, xpath: str, element: str) -> dict:
401
+ """
402
+ Override a configuration object on the firewall using the XML API.
403
+
404
+ Args:
405
+ xpath (str): The XPath of the configuration object to be overridden.
406
+ element (str): The XML element with the new override settings.
407
+
408
+ Returns:
409
+ dict: The result of the override operation, typically containing 'status' and 'msg'.
410
+ """
411
+
412
+ # Base API call for override
413
+ params = {
414
+ "type": "config",
415
+ "action": "override",
416
+ "xpath": xpath,
417
+ "element": element
418
+ }
419
+
420
+ # Make the API call
421
+ try:
422
+ parsed_response = self.xml_request(params=params, timeout=8)
423
+ status = parsed_response['response']['@status']
424
+ message = parsed_response['response'].get('msg', 'No message provided')
425
+
426
+ return {'status': status, 'msg': message}
427
+ except requests.exceptions.HTTPError as e:
428
+ logging.error(f"HTTP error occurred: {e}")
429
+ return {'status': 'error', 'msg': str(e)}
430
+ except requests.exceptions.ConnectionError as e:
431
+ logging.error(f"Connection error occurred: {e}")
432
+ return {'status': 'error', 'msg': 'Connection error'}
433
+ except requests.exceptions.Timeout as e:
434
+ logging.error(f"Request timed out: {e}")
435
+ return {'status': 'error', 'msg': 'Timeout'}
436
+ except Exception as e:
437
+ logging.error(f"An unexpected error occurred: {e}")
438
+ return {'status': 'error', 'msg': 'Unexpected error'}
439
+
440
+ def send_command(self, cmd: str, value: Optional[Any] = None, timeout: int = 60) -> Dict[str, Any]:
441
+ """
442
+ Send a command to the firewall.
443
+
444
+ :param cmd: Command to be sent.
445
+ :param value: Additional value for the command, if any.
446
+ :param timeout: Timeout for the request in seconds.
447
+ :return: Parsed XML response as a dictionary.
448
+ """
449
+
450
+ params = {'type': 'op', 'cmd': self.command_to_payload(cmd, value)}
451
+ try:
452
+ response = self.xml_request(params=params, timeout=timeout)
453
+ return response
454
+ except requests.exceptions.RequestException as e:
455
+ logger.error(f"Request error: {e}")
456
+ return {'response': {'@status': 'error', 'msg': str(e)}}
457
+ except Exception as e: # Catching generic exceptions for logging and error reporting
458
+ logger.error(f"Parsing error or other exception: {e}")
459
+ return {'response': {'@status': 'error', 'msg': 'An unexpected error occurred'}}
460
+
461
+ def command_to_payload(self, cmd: str, value: Optional[Any]) -> str:
462
+ """
463
+ Convert command to payload. This method needs to be implemented based on specific requirements.
464
+
465
+ :param cmd: Command to be sent.
466
+ :param value: Additional value for the command, if any.
467
+ :return: The command converted into a payload string.
468
+ """
469
+ return self.string_to_xml(cmd, value=value)
470
+
471
+ def wait_for_job_completion(self, job_id: str) -> None:
472
+ """
473
+ Poll the job status until completion.
474
+
475
+ :param job_id: The ID of the job to poll.
476
+ """
477
+ params = {'type': 'op', 'cmd': self.string_to_xml('show jobs id', value=job_id)}
478
+
479
+ while True:
480
+ try:
481
+ job = self.xml_request(params=params, timeout=8)
482
+ status = job['response']['result']['job']['status']
483
+
484
+ if status == 'FIN':
485
+ logger.info(f"Job {job_id} is 100% complete.")
486
+ break
487
+
488
+ progress = job['response']['result']['job']['progress']
489
+ logger.info(f"On device {self.hostname}, Job {job_id} is {progress}% complete.")
490
+
491
+ except requests.exceptions.ConnectionError as ce:
492
+ if 'HTTPSConnectionPool' in str(ce):
493
+ logger.warning(f"HTTPS connection pool issue encountered: {ce}. Retrying in 30 seconds...")
494
+ time.sleep(10)
495
+ continue # Try the request again after sleep
496
+ else:
497
+ logger.error(f"Connection error encountered: {ce}")
498
+ break
499
+ except requests.exceptions.RequestException as e:
500
+ logger.error(f"Error polling job status: {e}")
501
+ break
502
+
503
+ time.sleep(5)
504
+
505
+ def op(self, cmd: str, value: str = None, wait: bool = False) -> Dict[str, Any]:
506
+ """
507
+ Execute a command on the firewall.
508
+
509
+ :param wait: Wait for the job to finish or not
510
+ :param value: Value in to go with the command.
511
+ :param cmd: The command to execute.
512
+ :return: The parsed result of the command execution.
513
+ """
514
+
515
+ result = self.send_command(cmd, value)
516
+
517
+ if result['response']['@status'] == 'success' and wait:
518
+ job_id = result['response']['result'].get('job')
519
+ if job_id:
520
+ self.wait_for_job_completion(job_id)
521
+
522
+ return self.parse_result(result)
523
+
524
+ @staticmethod
525
+ def parse_result(result: Dict[str, Any]) -> Dict[str, Any]:
526
+ """
527
+ Parse and return the command execution result.
528
+
529
+ :param result: The raw result from the command execution.
530
+ :return: A dictionary containing the status and result of the command execution.
531
+ """
532
+ if 'result' in result['response']:
533
+ return {'status': result['response']['@status'], 'result': result['response']['result']}
534
+ else:
535
+ return {'status': result['response']['@status'],
536
+ 'result': {k: v for k, v in result['response'].items() if k != '@status'}}
537
+
538
+ def clear_sessions(self):
539
+ """
540
+ Clear all sessions on the firewall
541
+ :return:
542
+ """
543
+ self.op('clear session all')
544
+
545
+ def restart_management(self):
546
+ """
547
+ Restart the management server on the firewall
548
+ :return:
549
+ """
550
+ self.op('debug software restart process management-server')
551
+
552
+ def commit(self, wait: bool = False) -> Dict:
553
+ """
554
+ Commit changes to Panorama or Firewall. If "wait" argument is True, then wait for the commit job to finish.
555
+ """
556
+ if not isinstance(wait, bool):
557
+ raise ValueError('The wait attribute must be True or False')
558
+
559
+ params = {
560
+ 'key': self.api_key,
561
+ 'type': 'commit',
562
+ 'cmd': '<commit></commit>'
563
+ }
564
+ logger.debug(f'Commit changes to {self.hostname}.')
565
+ parsed_response = self.xml_request(params=params)
566
+
567
+ if parsed_response.get('response', {}).get('@status') == 'success':
568
+ job_id = parsed_response.get('response', {}).get('result', {}).get('job')
569
+ if wait and job_id:
570
+ # Waiting for completion
571
+ logger.debug(f'Waiting for commit job id {job_id} to complete.')
572
+ commmit_status = self.wait_for_commit_to_finish(job_id)
573
+ return {**commmit_status, 'job_id': job_id}
574
+ elif job_id:
575
+ # Job ID present but not waiting for completion
576
+ return {'status': 'pending', 'job_id': job_id}
577
+ else:
578
+ # Success but no job ID found
579
+ return {'status': 'success', 'message': 'Commit successful, no job ID provided.'}
580
+ else:
581
+ return {'status': parsed_response.get('response', {}).get('@status'),
582
+ 'msg': parsed_response.get('response', {}).get('msg', 'Unknown error')}
583
+
584
+ def wait_for_commit_to_finish(self, job_id) -> dict:
585
+ """
586
+ Wait for the commit job to finish and return the final status.
587
+ """
588
+ while True:
589
+ result = self.op('show jobs id', value=job_id)
590
+ job_status = result.get('result', {}).get('job', {}).get('status')
591
+
592
+ if job_status == 'FIN':
593
+ logger.info(f"Commit job {job_id} finished.")
594
+ return {'status': result.get('result', {}).get('job', {}).get('result')}
595
+ time.sleep(10) # Adjust sleep time as needed
596
+
597
+ def get_license_info(self) -> Tuple[List[str], Dict[str, Union[str, int]]]:
598
+ """
599
+ Get the license info for this device.
600
+ Returns a tuple containing a list of column names and a dictionary with license information.
601
+ """
602
+ columns = [
603
+ 'serial', 'device name', 'support', 'Threat Prevention', 'GlobalProtect Gateway',
604
+ 'PAN-DB URL Filtering', 'DNS Security', 'WildFire License', 'Logging Service', 'PA-VM',
605
+ 'Advanced URL Filtering', 'Device Management License'
606
+ ]
607
+
608
+ try:
609
+ response = self.op('request license info')
610
+ if response.get('status') == 'success' and response.get('result', {}).get('licenses', {}).get('entry',
611
+ None) is not None:
612
+ licenses = {
613
+ 'device name': self.hostname,
614
+ 'serial': self.serial
615
+ }
616
+ for lic in response['result']['licenses']['entry']:
617
+ if lic.get('expires') and lic.get('expires') != 'Never':
618
+ endDateRAW = datetime.strptime(lic['expires'], '%B %d, %Y')
619
+ endDateRAW = int(endDateRAW.timestamp()) * 1000
620
+ feature_name = lic.get('feature')
621
+ if lic.get('feature') == 'Premium':
622
+ licenses.update({'support': endDateRAW})
623
+ else:
624
+ licenses.update({feature_name: endDateRAW})
625
+ return columns, licenses
626
+ else:
627
+ raise ValueError("Failed to retrieve license information or no licenses found.")
628
+ except Exception as e:
629
+ raise RuntimeError(f"Error retrieving license information: {e}")
630
+
631
+ def refresh_license(self):
632
+ """
633
+ Fetch the licenses from palo alto networks custom support portal
634
+ :return:
635
+ """
636
+ return self.op('request license fetch')
637
+
638
+ @staticmethod
639
+ def version_key(version):
640
+ # For finding the newest version of the content
641
+ return tuple(map(int, version.split('-')))
642
+
643
+ def update_content(self):
644
+ """
645
+ Download the latest version of the Apps & Threats and install it.
646
+ :return: status and result of the operation
647
+ """
648
+
649
+ download = self.op('request content upgrade download latest', wait=True)
650
+
651
+ if download['status'] == 'success':
652
+ current_info = self.op('request content upgrade info')
653
+ app_version_list = []
654
+ current_verison = ''
655
+ for entry in current_info['result']['content-updates']['entry']:
656
+ app_version_list.append(entry['app-version'])
657
+ if entry['current'] == 'yes':
658
+ current_verison = entry['app-version']
659
+ hightest_version = max(app_version_list, key=self.version_key)
660
+ if current_verison != hightest_version:
661
+ # There is a newer version, upgrade to it.
662
+ install = self.op('request content upgrade install version', value='latest', wait=True)
663
+ return install
664
+ else:
665
+ logging.info(f'Content is already up to date. Nothing to do.')
666
+ return {'status': 'success',
667
+ 'msg': 'Content is already up to date. Nothing to do.'}
668
+
669
+ def update_av(self):
670
+ """
671
+ Downlaod and install the lastest AntiVirus.
672
+ You can only get the Anti-Virus content if the Threat Prevention license is on the firewall
673
+ :return: status and result of the operation
674
+ """
675
+ download = self.op('request anti-virus upgrade download latest', wait=True)
676
+ if download['status'] == 'success':
677
+ current_info = self.op('request anti-virus upgrade info')
678
+ app_version_list = []
679
+ current_verison = ''
680
+ for entry in current_info['result']['content-updates']['entry']:
681
+ app_version_list.append(entry['app-version'])
682
+ if entry['current'] == 'yes':
683
+ current_verison = entry['app-version']
684
+ hightest_version = max(app_version_list, key=self.version_key)
685
+ if current_verison != hightest_version:
686
+ install = self.op('request anti-virus upgrade install version', value='latest', wait=True)
687
+ return install
688
+ else:
689
+ logging.info(f'Anti-Virus is already up to date. Nothing to do.')
690
+ return {'status': 'success',
691
+ 'msg': 'Anti-Virus is already up to date. Nothing to do.'}
692
+
693
+ def update_hostname(self, hostname):
694
+ xpath = "/config/devices/entry[@name='localhost.localdomain']/deviceconfig/system/hostname"
695
+ element = f"<hostname>{hostname}</hostname>"
696
+ self.hostname = hostname
697
+ return self.edit_xml(xpath, element)
698
+
699
+
700
+ class Firewall(PAN):
701
+ # Firewall object using REST API
702
+ valid_location = ['vsys', 'panorama-pushed', 'predefined', '']
703
+
704
+ def __init__(self, base_url: str, **kwargs):
705
+ super().__init__(base_url, **kwargs)
706
+ self.model: str = self.SystemInfo.get('model')
707
+ self.family: str = self.SystemInfo.get('family')
708
+ self.serial: str = self.SystemInfo.get('serial')
709
+ self.vsys_list: List[str] = self.get_vsys_list()
710
+
711
+ def get_vsys_list(self) -> List[str]:
712
+ """
713
+ Fetches a list of virtual systems (vsys) configured on the firewall.
714
+
715
+ Returns:
716
+ List[str]: A list of vsys identifiers.
717
+ """
718
+ if self.SystemInfo.get('multi-vsys', 'off') == 'off':
719
+ return ['vsys1']
720
+
721
+ try:
722
+ cmd_response = self.op('show system setting target-vsys')
723
+ if cmd_response.get('status') == 'success':
724
+ return cmd_response.get('result', [])
725
+ else:
726
+ # Log the error or raise an exception as appropriate
727
+ raise ValueError("Failed to retrieve vsys list.")
728
+ except Exception as e:
729
+ # Log the exception or handle it as needed
730
+ raise RuntimeError(f"Error retrieving vsys list: {e}")
731
+
732
+ def connect_to_panorama(self, authkey: str, panorama_ip: str) -> str:
733
+ """
734
+ Connects this firewall to a Panorama management server using an auth key and the Panorama's IP address.
735
+
736
+ Args:
737
+ authkey (str): The authentication key for registering with Panorama.
738
+ panorama_ip (str): The IP address of the Panorama server.
739
+
740
+ Returns:
741
+ str: success or error
742
+ """
743
+ xpath = "/config/devices/entry[@name='localhost.localdomain']/deviceconfig/system/panorama/local-panorama"
744
+ element = f'<panorama-server>{panorama_ip}</panorama-server>'
745
+
746
+ try:
747
+ add_panorama_result = self.set_xml(xpath, element)
748
+ if add_panorama_result.get('status') == 'success':
749
+ op_result = self.op('request authkey set', value=authkey)
750
+ if op_result.get('status') == 'success':
751
+ return {'status': 'success', 'msg': f'Added {self.serial} to Panorama {panorama_ip}.'}
752
+ else:
753
+ error_msg = f"Failed to set authkey on {self.hostname}. Response: {op_result}"
754
+ logger.error(error_msg)
755
+ return {'status': 'error', 'msg': error_msg}
756
+ else:
757
+ error_msg = f"Could not add Panorama server {panorama_ip} to firewall {self.hostname}. Response: {add_panorama_result}"
758
+ logger.error(error_msg)
759
+ return {'status':'error', 'msg': error_msg}
760
+ except Exception as e:
761
+ logger.exception(f"Unexpected error connecting {self.hostname} to Panorama {panorama_ip}: {e}")
762
+ return {'status': 'error', 'msg': f'Unexpected error connecting {self.hostname} to Panorama {panorama_ip}, {e}.'}
763
+
764
+ def upgrade_to_version(self, new_version: str):
765
+ """
766
+ Perform system upgrade of a firewall device to a specified software version of PAN-OS. The method checks the
767
+ current version, determines if step upgrades are necessary, and handles download, installation, and reboot
768
+ procedures as needed.
769
+
770
+ Arguments:
771
+ new_version (str): Target PAN-OS version to which the device should be upgraded.
772
+
773
+ Returns:
774
+ dict: A dictionary containing the status of the operation ('success' or 'failure') and a message indicating
775
+ the result or any error encountered.
776
+
777
+ """
778
+ def download_and_install(v2: str):
779
+ """
780
+ Represents a firewall class that inherits from the PAN class. Provides functionality
781
+ for upgrading the firewall to a specified version. The upgrade process involves
782
+ downloading, installing the software version, and rebooting the device while checking
783
+ the device's status during the reboot process.
784
+
785
+ Attributes:
786
+ None
787
+ """
788
+ logger.debug(f'Firewall {self.serial} attempting to download version {v2}')
789
+ op_download = self.op('request system software download version', value=v2, wait=True)
790
+ logger.debug(f'Firewall {self.serial} status of download operation: {op_download}')
791
+ if op_download['status'] == 'success':
792
+ op_install = self.op('request system software install version', value=v2, wait=True)
793
+ logger.debug(f'Firewall {self.serial} status of install operation: {op_install}')
794
+ if op_install['status'] == 'success':
795
+ self.op(f'request restart system')
796
+ loop = 0
797
+ while True:
798
+ # Wait for firewall to complete rebooting
799
+ logger.info(f'Waiting for firewall {self.serial} to come back after reboot.')
800
+ if loop == 0:
801
+ time.sleep(300)
802
+ else:
803
+ time.sleep(60)
804
+ try:
805
+ new_sysinfo = self.op('show system info')
806
+ if new_sysinfo.get('result'):
807
+ if new_sysinfo['status'] == 'success':
808
+ # Make sure the current object's version attribute is updated.
809
+ if self.SystemInfo["sw-version"] == v2:
810
+ self.ver = self.ver_from_sw_version(self.SystemInfo['sw-version'])
811
+
812
+ return {'status': 'success',
813
+ 'msg': f'Device {self.serial} upgraded to {new_version}.'}
814
+ else:
815
+ return {'status': 'failure',
816
+ 'msg': f'Device {self.serial} failed to upgrade to {new_version}.'}
817
+ except requests.exceptions.ConnectionError:
818
+ # while the firewall is down, we will get this error.
819
+ loop += 1
820
+ continue
821
+ except requests.exception.NewConnectionError:
822
+ # while the firewall is down, we will get this error.
823
+ loop += 1
824
+ continue
825
+ except requests.exceptions.ReadTimeout:
826
+ # The firewall is up, but autocommit is not yet done.
827
+ loop += 1
828
+ continue
829
+ loop += 1
830
+ if loop > 20:
831
+ return {'status': 'failure',
832
+ 'msg': f'Device {self.serial} failed to come back after reboot.'}
833
+
834
+ # Map the steps needed to upgrade from one major version to the next.
835
+ upgrade_map = {'9.1': '10.0',
836
+ '10.0': '10.1',
837
+ '10.1': '10.2',
838
+ '10.2': '11.0'}
839
+
840
+ if self.SystemInfo['sw-version'] == new_version:
841
+ # the current and requested new version are the same, nothing to do.
842
+ return {'status': 'success',
843
+ 'msg': f'The device {self.serial} is already at version {new_version}. Nothing to do.'}
844
+ # Get current list of available images to download
845
+ op_check = self.op('request system software check')
846
+ if op_check['status'] == 'success':
847
+ logger.debug(f'Firewall {self.serial} status of check operation: success')
848
+ else:
849
+ logger.error(f'Firewall {self.serial} status of check operation: {op_check}')
850
+ return {'status': 'failure',
851
+ 'msg': f'Device {self.serial} failed to retrieve available software versions.'}
852
+
853
+ v1_components = self.SystemInfo['sw-version'].split('.')
854
+ v2_components = new_version.split('.')
855
+ v1_major = v1_components[0] + '.' + v1_components[1]
856
+ v2_major = v2_components[0] + '.' + v2_components[1]
857
+ logger.debug(f'Attempting to upgrade {self.serial} from {self.SystemInfo["sw-version"]} to {new_version}.')
858
+ if v1_major != v2_major:
859
+ # as of PANOS 10.2 you no longer need to step upgrade.
860
+ if float(v1_major) >= 10.2:
861
+ logger.debug(f'Firewall {self.serial} is at a version greater than or equal to 10.2.')
862
+ v2 = f'{v2_major}.0'
863
+ logger.debug(f'Firewall {self.serial} is attempting to download to {v2}.')
864
+ op_download = self.op('request system software download version', value=v2, wait=True)
865
+ logger.debug(f'Result of download: {op_download}')
866
+ if op_download['status'] == 'success':
867
+ result = download_and_install(new_version)
868
+ return result
869
+ # We have to step upgrade to get to version 2
870
+ while v1_major != v2_major:
871
+ if upgrade_map.get(v1_major) == v2_major:
872
+ # The next version is the next major version, just download the major version, then download and install
873
+ # the minor version.
874
+ v2 = f'{upgrade_map[v1_major]}.0'
875
+ # download the next major version, no need to install it.
876
+ op_download = self.op('request system software download version', value=v2, wait=True)
877
+ if op_download['status'] == 'success':
878
+ # Download and install the new version
879
+ result = download_and_install(new_version)
880
+ return result
881
+ else:
882
+ error_message = 'Could not install new version of OS. Please upgrade manually.'
883
+ logger.error(error_message)
884
+ return {'status': 'failure',
885
+ 'msg': error_message}
886
+ else:
887
+ # Need to step upgrade to the new version, so keep downloading and installing major versions until
888
+ # we reach the final major version
889
+ next_version = f'{upgrade_map[v1_major]}.0'
890
+ result = download_and_install(next_version)
891
+ if result['status'] == 'success':
892
+ logger.info(f'Step upgrade to version {next_version} complete.')
893
+ else:
894
+ error_message = 'Could not install new version of OS. Please upgrade manually.'
895
+ logger.error(error_message)
896
+ return {'status': 'failure',
897
+ 'msg': error_message}
898
+ v1_major = upgrade_map[v1_major]
899
+ else:
900
+ # The two major version are equal, so just download and install the patch.
901
+ logger.debug(f'Firewall {self.serial} is already at the same major version as {new_version}. ')
902
+ result = download_and_install(new_version)
903
+ return result
904
+
905
+ def sip_disable_alg(self) -> str:
906
+ """
907
+ Disables the SIP Application Layer Gateway (ALG) on the firewall.
908
+
909
+ Returns:
910
+ str: The status of the operation ('success' or 'error').
911
+ """
912
+ xpath = "/config/shared/alg-override/application/entry[@name='sip']"
913
+ element = "<alg-disabled>yes</alg-disabled>"
914
+ result = self.set_xml(xpath=xpath, element=element)
915
+
916
+ if result.get('status') == 'success':
917
+ return 'success'
918
+ else:
919
+ logging.error(f"Failed to disable SIP ALG. Response: {result}")
920
+ return 'error'
921
+
922
+ def set_telemetry(self, region: str):
923
+ xpath = "/config/devices/entry[@name='localhost.localdomain']/deviceconfig/system/device-telemetry"
924
+ element = f"<region>{region}</region>"
925
+ result = self.set_xml(xpath=xpath, element=element)
926
+ if result.get('status') == 'success':
927
+ return 'success'
928
+ else:
929
+ if 'set failed, may need to override template object' in result.get('msg', ''):
930
+ override_result = self.override(xpath=xpath, element=element)
931
+ if override_result['status'] == 'success':
932
+ logger.info(
933
+ f"Successfully performed telemetry override for device {self.hostname}. Retrying telemetry set.")
934
+ # Retry setting telemetry after the override
935
+ retry_result = self.set_xml(xpath, element)
936
+ if retry_result['status'] == 'success':
937
+ logger.info(f"Successfully set telemetry for device {self.hostname} after override.")
938
+ mark_step_completed(self.serial, 'telemetry')
939
+ else:
940
+ logger.error(
941
+ f"Failed to set telemetry for device {self.hostname} after override. {retry_result['msg']}")
942
+ else:
943
+ logger.error(f"Failed to override telemetry for device {self.hostname}. {override_result['msg']}")
944
+
945
+ logging.error(f"Failed to set telemetry. Response: {result}")
946
+ return 'error'
947
+
948
+
949
+
950
+ class Panorama(PAN):
951
+ # Panorama object using REST API
952
+ valid_location = ['shared', 'device-group', 'predefined']
953
+
954
+ def __init__(self, base_url: str, **kwargs):
955
+ super().__init__(base_url, **kwargs)
956
+ self.licensed_device_capacity: st = self.SystemInfo.get('licensed-device-capacity')
957
+ self.model: str = self.SystemInfo.get('model')
958
+ self.device_groups_list: Dict[str, Dict[str, str]] = self.get_device_groups()
959
+ self.templates: Dict[str, str] = self.get_templates(stack=False)
960
+ self.template_stacks: Dict[str, str] = self.get_templates(stack=True)
961
+ self.serial: str = self.SystemInfo.get('serial')
962
+
963
+ def get_device_groups(self) -> Optional[Dict[str, Dict[str, str]]]:
964
+ """
965
+ Retrieves a list of all device groups and their hierarchy from Panorama using the XML API.
966
+
967
+ Returns:
968
+ Optional[Dict[str, Dict[str, str]]]: A dictionary with the child DG as key and a dictionary containing
969
+ the parent DG as value, or None if an error occurs.
970
+ """
971
+ cmd = self.op('show dg-hierarchy')
972
+ if cmd.get('status') == 'success':
973
+ dg_groups: Dict[str, Dict[str, str]] = {}
974
+ dg_hierarchy = cmd.get('result', {}).get('dg-hierarchy', {}).get('dg', [])
975
+
976
+ def process_dg(dg, parent='shared'):
977
+ if isinstance(dg, list):
978
+ for child_dg in dg:
979
+ dg_groups[child_dg.get("@name")] = {'parent': parent}
980
+ # Recursive call to process nested device groups
981
+ process_dg(child_dg.get("dg", []), child_dg.get("@name"))
982
+ elif isinstance(dg, dict):
983
+ dg_groups[dg.get("@name")] = {'parent': parent}
984
+ # Recursive call to process nested device groups
985
+ process_dg(dg.get("dg", []), dg.get("@name"))
986
+
987
+ process_dg(dg_hierarchy)
988
+ return dg_groups
989
+ else:
990
+ logging.error(f'Could not get device groups for Panorama.')
991
+ return None
992
+
993
+ def commit_all(self, **kwargs):
994
+
995
+ def build_shared_policy(description, device_group_list, admin, force_template_values, include_template,
996
+ merge_with_candidate_cfg, validate_only):
997
+ """
998
+ Builds a shared policy XML structure with the given parameters.
999
+
1000
+ Parameters:
1001
+ - description (str): Description of the shared policy.
1002
+ - device_group_list (List[str]): List of device groups to be included.
1003
+ - admin (List[str]): List of admins to be included.
1004
+ - force_template_values (str): Whether to force template values.
1005
+ - include_template (str): Whether to include the template.
1006
+ - merge_with_candidate_cfg (str): Whether to merge with candidate config.
1007
+ - validate_only (str): Whether the operation is validated only.
1008
+
1009
+ Returns:
1010
+ - str: A string representation of the XML structure for the shared policy.
1011
+ """
1012
+ shared_policy_elem = ET.Element('shared-policy')
1013
+
1014
+ # Add description
1015
+ if description:
1016
+ ET.SubElement(shared_policy_elem, 'description').text = description
1017
+
1018
+ # Dynamically add device groups and their entries
1019
+ device_group_elem = ET.SubElement(shared_policy_elem, 'device-group')
1020
+ for device_group in device_group_list:
1021
+ ET.SubElement(device_group_elem, 'entry', {'name': device_group})
1022
+
1023
+ # Add admin elements
1024
+ admin_elem = ET.SubElement(shared_policy_elem, 'admin')
1025
+ for admin_name in admin:
1026
+ ET.SubElement(admin_elem, 'member').text = admin_name
1027
+ # Add other elements
1028
+ ET.SubElement(shared_policy_elem, 'force-template-values').text = force_template_values
1029
+ ET.SubElement(shared_policy_elem, 'include-template').text = include_template
1030
+ ET.SubElement(shared_policy_elem, 'merge-with-candidate-cfg').text = merge_with_candidate_cfg
1031
+ ET.SubElement(shared_policy_elem, 'validate-only').text = validate_only
1032
+
1033
+ # Convert to string for inclusion in the final XML
1034
+ return ET.tostring(shared_policy_elem, encoding='unicode')
1035
+
1036
+ def build_template_stack(description: str, admin: List[str], force_template_values: str,
1037
+ merge_with_candidate_cfg: str, validate_only: str, device: List[str],
1038
+ name_list: List[str]) -> str:
1039
+ """
1040
+ Constructs an XML structure representing a template stack configuration for Palo Alto Networks devices.
1041
+
1042
+ This function creates a template stack element with specified details, including description,
1043
+ administrators, device members, and named entries. The resulting XML structure is intended for use
1044
+ in configuring template stacks on Palo Alto Networks firewalls or Panorama.
1045
+
1046
+ Parameters:
1047
+ - description (str): A text description of the template stack.
1048
+ - admin (List[str]): A list of administrator names to be included as members.
1049
+ - force_template_values (str): A string ('yes' or 'no') indicating whether to force template values.
1050
+ - merge_with_candidate_cfg (str): A string ('yes' or 'no') indicating whether to merge this configuration
1051
+ with the candidate configuration.
1052
+ - validate_only (str): A string ('yes' or 'no') indicating whether the operation should only validate
1053
+ the configuration without applying it.
1054
+ - device (List[str]): A list of device names to be included in the template stack.
1055
+ - name_list (List[str]): A list of names for entries within the template stack.
1056
+
1057
+ Returns:
1058
+ - str: A string representation of the XML structure for the template stack configuration.
1059
+
1060
+ Note:
1061
+ - The 'admin' parameter's members are added under the 'admin' element, each as a 'member' sub-element.
1062
+ - Devices are added under a 'device' element, each as a 'member' sub-element.
1063
+ - Named entries are added directly under the root 'template_stack' element as 'entry' elements with a 'name' attribute.
1064
+ """
1065
+ template_stack_elem = ET.Element('template_stack')
1066
+
1067
+ # Add description
1068
+ if description:
1069
+ ET.SubElement(template_stack_elem, 'description').text = description
1070
+
1071
+ # Add admin elements
1072
+ admin_elem = ET.SubElement(shared_policy_elem, 'admin')
1073
+ for admin_name in admin:
1074
+ ET.SubElement(admin_elem, 'member').text = admin_name
1075
+
1076
+ # Add other elements
1077
+ ET.SubElement(template_stack_elem, 'force-template-values').text = force_template_values
1078
+ ET.SubElement(template_stack_elem, 'merge-with-candidate-cfg').text = merge_with_candidate_cfg
1079
+ ET.SubElement(template_stack_elem, 'validate-only').text = validate_only
1080
+
1081
+ # Dynamically add devices
1082
+ device_elem = ET.SubElement(template_stack_elem, 'device')
1083
+ for device_member in device:
1084
+ ET.SubElement(device_elem, 'member').text = device_member
1085
+
1086
+ # Dynamically add names as entry elements
1087
+ for name in name_list:
1088
+ ET.SubElement(template_stack_elem, 'entry', {'name': name})
1089
+
1090
+ # Convert to string for inclusion in the final XML
1091
+ return ET.tostring(template_stack_elem, encoding='unicode')
1092
+
1093
+ def build_log_collector_config(description, log_collector_group):
1094
+ # Create the root element for the log collector config
1095
+ log_collector_config_elem = ET.Element('log-collector-config')
1096
+
1097
+ # Add description
1098
+ if description:
1099
+ ET.SubElement(log_collector_config_elem, 'description').text = description
1100
+
1101
+ # Add log collector group if specified
1102
+ if log_collector_group:
1103
+ ET.SubElement(log_collector_config_elem, 'log-collector-group').text = log_collector_group
1104
+
1105
+ # Convert to string for inclusion in the final XML
1106
+ return ET.tostring(log_collector_config_elem, encoding='unicode')
1107
+
1108
+ root = ET.Element('commit-all')
1109
+
1110
+ # Check and build log collector config if applicable
1111
+ if kwargs.get('log_collector_config', False):
1112
+ log_collector_xml = build_log_collector_config(
1113
+ description=kwargs.get('description', ''),
1114
+ log_collector_group=kwargs.get('log_collector_group', '')
1115
+ )
1116
+ root.append(ET.fromstring(log_collector_xml))
1117
+
1118
+ # Check and build shared policy if applicable
1119
+ if kwargs.get('shared_policy', False):
1120
+ shared_policy_xml = build_shared_policy(
1121
+ description=kwargs.get('description', ''),
1122
+ device_group_list=kwargs.get('device_group_list', []),
1123
+ admin=kwargs.get('admin', []),
1124
+ force_template_values=kwargs.get('force_template_values', 'no'),
1125
+ include_template=kwargs.get('include_template', 'yes'),
1126
+ merge_with_candidate_cfg=kwargs.get('merge_with_candidate_cfg', 'yes'),
1127
+ validate_only=kwargs.get('validate_only', 'no')
1128
+ )
1129
+ root.append(ET.fromstring(shared_policy_xml))
1130
+
1131
+ # Check and build template stack if applicable
1132
+ if kwargs.get('template_stack', False):
1133
+ template_stack_xml = build_template_stack(
1134
+ description=kwargs.get('description', ''),
1135
+ admin=kwargs.get('admin', []),
1136
+ force_template_values=kwargs.get('force_template_values', 'no'),
1137
+ merge_with_candidate_cfg=kwargs.get('merge_with_candidate_cfg', 'yes'),
1138
+ validate_only=kwargs.get('validate_only', 'no'),
1139
+ device=kwargs.get('device', []),
1140
+ name_list=kwargs.get('name', [])
1141
+ )
1142
+ root.append(ET.fromstring(template_stack_xml))
1143
+
1144
+ params = {
1145
+ 'key': self.api_key,
1146
+ 'type': 'commit',
1147
+ 'action': 'all',
1148
+ 'cmd': ET.tostring(root, encoding='unicode')
1149
+ }
1150
+ response = self.xml_request(params=params)
1151
+ if response.get('response', {}).get('@status', {}) == 'success':
1152
+ return response.get('response', {}).get('result', {}).get('job')
1153
+
1154
+ def get_device_license_info(self) -> Tuple[List[str], List[Dict[str, any]]]:
1155
+ """
1156
+ Retrieves license information for all devices managed by Panorama.
1157
+
1158
+ Returns:
1159
+ Tuple[List[str], List[Dict[str, any]]]: A tuple containing a list of column headers and a list of dictionaries
1160
+ with license information for each device.
1161
+ """
1162
+ report = []
1163
+ columns = [
1164
+ 'serial', 'device name', 'support', 'Threat Prevention', 'GlobalProtect Gateway',
1165
+ 'PAN-DB URL Filtering', 'DNS Security', 'WildFire License', 'Advanced WildFire License', 'SD WAN',
1166
+ 'Advanced URL Filtering', 'Premium Partner', 'Device Management License'
1167
+ ]
1168
+ op_response = self.op('request batch license info')
1169
+
1170
+ if op_response.get('status') == 'success':
1171
+ list_of_devices = op_response.get('result', {}).get('devices', {}).get('entry', [])
1172
+
1173
+ for device in list_of_devices:
1174
+ licenses = {'serial': device.get('serial-no'), 'device name': device.get('devicename')}
1175
+ entry = device.get('licenses', {}).get('entry')
1176
+ # Check if 'entry' is a dictionary and convert it to a list if it is
1177
+ if isinstance(entry, dict):
1178
+ entry = [entry]
1179
+ for pan_license in entry:
1180
+ license_type = pan_license.get('type')
1181
+ expiry_date = pan_license.get('expiry-date')
1182
+
1183
+ if license_type == 'SUP':
1184
+ licenses['support'] = expiry_date
1185
+ elif license_type == 'SUB' or 'RENSUB':
1186
+ licenses[pan_license.get('@name')] = expiry_date
1187
+ report.append(licenses)
1188
+
1189
+ return columns, report
1190
+
1191
+ def get_fw_name_list(self) -> List[str]:
1192
+ """
1193
+ Retrieves a list of all connected firewall hostnames managed by Panorama.
1194
+
1195
+ Returns:
1196
+ List[str]: A list containing the hostnames of all connected firewalls.
1197
+ """
1198
+ op_response = self.op('show devices connected')
1199
+ firewall_name_list = []
1200
+
1201
+ if op_response.get('response', {}).get('@status') == 'success':
1202
+ devices = op_response.get('response', {}).get('result', {}).get('devices', {}).get('entry', [])
1203
+
1204
+ for device in devices:
1205
+ hostname = device.get('hostname')
1206
+ if hostname:
1207
+ firewall_name_list.append(hostname)
1208
+
1209
+ return firewall_name_list
1210
+
1211
+ def get_firewall_connected(self) -> List[Dict[str, str]]:
1212
+ """
1213
+ Retrieves a list of all connected firewalls with their details from Panorama.
1214
+
1215
+ Returns:
1216
+ List[Dict[str, str]]: A list of dictionaries, each containing details of a firewall.
1217
+ """
1218
+ connected = self.op('show devices connected')
1219
+ if connected.get('status') == 'success':
1220
+ fw_list = connected.get('result', {}).get('devices', {}).get('entry', [])
1221
+ return [
1222
+ {'serial': fw.get('serial', ''),
1223
+ 'ip_address': fw.get('ip-address', ''),
1224
+ 'hostname': fw.get('hostname', ''),
1225
+ 'model': fw.get('model', '')}
1226
+ for fw in fw_list
1227
+ ]
1228
+ return []
1229
+
1230
+ def get_panorama_authkey(self, existing_key_name: Optional[str] = None, new_key_name: str = 'key1') -> Optional[str]:
1231
+ """
1232
+ Retrieves or generates an authorization key for connecting a firewall to Panorama.
1233
+
1234
+ Parameters:
1235
+ existing_key_name (Optional[str]): Name of an existing auth key in Panorama. If provided, the function will
1236
+ try to retrieve this key if it's valid.
1237
+ new_key_name (str): Name of the new key to create. This parameter is used if a new key needs to be generated.
1238
+
1239
+ Returns:
1240
+ Optional[str]: The auth key if successful, None otherwise.
1241
+ """
1242
+ authkey = None
1243
+
1244
+ def add_key(name: str) -> None:
1245
+ nonlocal authkey
1246
+ op = self.op(f'request authkey add name', value=name)
1247
+ if op.get('status') == 'success':
1248
+ authkey = op.get('result', {}).get('authkey')
1249
+
1250
+ def list_keys() -> list:
1251
+ op = self.op('request authkey list', value='*')
1252
+ if op.get('status') == 'success':
1253
+ authkey = op.get('result', {}).get('authkey', None)
1254
+ if authkey is None:
1255
+ return [] # Handle None case by returning an empty list
1256
+ entry = authkey.get('entry', [])
1257
+ if isinstance(entry, list):
1258
+ return entry
1259
+ else:
1260
+ return [entry]
1261
+ return []
1262
+
1263
+ if existing_key_name:
1264
+ op = self.op(f'request authkey list', value=existing_key_name)
1265
+ if op.get('status') == 'success':
1266
+ authkey_entry = op.get('result', {}).get('authkey', {}).get('entry', {})
1267
+ lifetime = authkey_entry.get('lifetime')
1268
+ count = authkey_entry.get('count')
1269
+ if int(lifetime) > 600 and int(count) > 1:
1270
+ return authkey_entry.get('key')
1271
+ else:
1272
+ self.op(f'request authkey delete', value=existing_key_name)
1273
+
1274
+ add_key(new_key_name if not authkey else new_key_name)
1275
+
1276
+ # After adding a new key or if no existing key was found, validate and return the new key
1277
+ for key in list_keys():
1278
+ if int(key.get('lifetime', 0)) > 600 and int(key.get('count', 0)) > 1 and key.get('name') == new_key_name:
1279
+ op_list_key = self.op(f'request authkey list', value=new_key_name)
1280
+ return op_list_key.get('result', {}).get('authkey', {}).get('entry', {}).get('key')
1281
+
1282
+ return authkey
1283
+
1284
+ def add_device(self, serial: str) -> str:
1285
+ """
1286
+ Adds a device to Panorama using the specified serial number.
1287
+
1288
+ Parameters:
1289
+ serial (str): Serial number of the device to add to Panorama.
1290
+
1291
+ Returns:
1292
+ str: The status of the operation ('success' or 'error').
1293
+ """
1294
+ # Ensure the serial number is properly formatted for XML
1295
+ serial_formatted = f"'{serial}'"
1296
+ xpath = '/config/mgt-config/devices'
1297
+ element = f'<entry name={serial_formatted}/>'
1298
+
1299
+ response = self.set_xml(xpath, element)
1300
+
1301
+ # Check the response status and return it
1302
+ return response.get('status', 'error')
1303
+
1304
+ def get_templates(self, stack: bool) -> list:
1305
+ """
1306
+ Returns a list of template names or template stack names based on the 'stack' parameter.
1307
+ Template Stacks vs Templates are determined if the key 'template-stack' is 'yes' or 'no'.
1308
+
1309
+ :param stack: A boolean indicating whether to return template stack names (True) or template names (False).
1310
+ :return: A list of template names or template stack names.
1311
+ """
1312
+ result: dict = self.op('show templates')
1313
+ template_list: list = []
1314
+ if result.get('status') == 'success':
1315
+ for entry in result.get('result', {}).get('templates', {}).get('entry', []):
1316
+ if stack and entry.get('template-stack', 'no') == 'yes':
1317
+ # Append the template stack name to the template_list
1318
+ template_list.append(entry.get('@name'))
1319
+ elif not stack and entry.get('template-stack', 'no') == 'no':
1320
+ # Append the template name to the template_list
1321
+ template_list.append(entry.get('@name'))
1322
+ return template_list
1323
+
1324
+ class PanProtocol(Protocol):
1325
+ # Define common interface that all ObjectTab subclasses must have
1326
+ location: str
1327
+ name: str
1328
+ PANDevice: Panorama | Firewall
1329
+ loc: str
1330
+ device_group: str
1331
+ description: str
1332
+
1333
+
1334
+ # This creates a type variable that can be any subclass of Object or Policy that fits the PanProtocol
1335
+ T = TypeVar('T', bound='PanProtocol')
1336
+
1337
+
1338
+ class Base:
1339
+ def __init__(self, PANDevice: Union[Panorama, Firewall], **kwargs):
1340
+ self.PANDevice: Union[Panorama, Firewall] = PANDevice
1341
+ self.entry: Dict = {}
1342
+ self.max_name_length: int = kwargs.get('max_name_length', 31)
1343
+ self.max_description_length: int = kwargs.get('max_description_length', 255)
1344
+ self.valid_location = self.PANDevice.valid_location
1345
+ self.composite_keys: Set[str] = set() # Stores composite keys for quick existence checks
1346
+ self.location = kwargs.get('location') or self.determine_location(kwargs)
1347
+ self.loc = self.determine_location(kwargs)
1348
+ self.name: str = kwargs.get('name')
1349
+ self.description: str = kwargs.get('description')
1350
+ self.tag: Dict[str, List[str]] = kwargs.get('tag')
1351
+ self.loc = self.determine_location(kwargs)
1352
+ self.device_group: str = kwargs.get('device_group')
1353
+ self.template: str = kwargs.get('template')
1354
+ self.template_stack: str = kwargs.get('template_stack')
1355
+ self.vsys = kwargs.get('vsys')
1356
+ # self.rulebase: str = None
1357
+
1358
+ def __str__(self):
1359
+ return self.name
1360
+
1361
+ def __repr__(self):
1362
+ class_name = self.__class__.__name__
1363
+ return f"{class_name}(name={self.name!r}, location={self.location!r})"
1364
+
1365
+ @property
1366
+ def name(self) -> str:
1367
+ return self._name
1368
+
1369
+ @name.setter
1370
+ def name(self, value: str) -> None:
1371
+ if value:
1372
+ if not self.PANDevice.valid_name(value, self.max_name_length):
1373
+ raise ValueError(f"Provided name '{value}' is invalid.")
1374
+ self._name = value
1375
+ self.entry.update({'@name': self._name})
1376
+ else:
1377
+ self._name = None
1378
+
1379
+ @property
1380
+ def description(self) -> str:
1381
+ return self._description
1382
+
1383
+ @description.setter
1384
+ def description(self, value: str) -> None:
1385
+ if value:
1386
+ if not isinstance(value, str):
1387
+ raise TypeError("Description must be a string.")
1388
+
1389
+ if len(value) > self.max_description_length:
1390
+ raise ValueError(f"Description exceeds the maximum length of {self.max_description_length} characters.")
1391
+
1392
+ self._description = value
1393
+ self.entry.update({'description': self._description})
1394
+
1395
+ @property
1396
+ def location(self) -> str:
1397
+ return self._location
1398
+
1399
+ @location.setter
1400
+ def location(self, value: str) -> None:
1401
+ if isinstance(self.PANDevice, Firewall):
1402
+ self._location = value
1403
+ elif value not in self.valid_location:
1404
+ raise ValueError(f"Invalid location. Must be one of: {self.valid_location}")
1405
+ self._location = value
1406
+
1407
+ @property
1408
+ def tag(self) -> Dict[str, List[str]]:
1409
+ return self._tag
1410
+
1411
+ @tag.setter
1412
+ def tag(self, value: Dict[str, List[str]]) -> None:
1413
+ if value:
1414
+ self.validate_member_dict(value, 'tag')
1415
+ self._tag = value
1416
+ self.entry.update({'tag': value})
1417
+
1418
+ @property
1419
+ def device_group(self) -> str:
1420
+ return self._device_group
1421
+
1422
+ @device_group.setter
1423
+ def device_group(self, value: str) -> None:
1424
+ if value:
1425
+ if not isinstance(self.PANDevice, Panorama):
1426
+ raise TypeError("device_group can only be set for Panorama devices.")
1427
+
1428
+ if self.location != 'device-group':
1429
+ raise ValueError("device_group can only be set when location is 'device-group'.")
1430
+
1431
+ if value not in self.PANDevice.device_groups_list:
1432
+ raise ValueError(f"Invalid device group: {value}. Must be one of: {self.PANDevice.device_groups_list}")
1433
+
1434
+ self._device_group = value
1435
+
1436
+ @property
1437
+ def vsys(self) -> str:
1438
+ return self._vsys
1439
+
1440
+ @vsys.setter
1441
+ def vsys(self, value: str) -> None:
1442
+ if value:
1443
+ # Check if the PANDevice is of type Firewall
1444
+ # if not isinstance(self.PANDevice, Firewall):
1445
+ # raise TypeError("vsys can only be set for Firewall devices.")
1446
+
1447
+ # # Check if the location is 'vsys' or 'panorama-pushed'
1448
+ # if self.location not in ['vsys', 'panorama-pushed']:
1449
+ # raise ValueError("vsys can only be set when location is 'vsys' or 'panorama-pushed'.")
1450
+
1451
+ # Check if the provided vsys value is in the PANDevice.vsys_list
1452
+ # if value not in self.PANDevice.vsys_list:
1453
+ # raise ValueError(f"Invalid vsys: {value}. Must be one of: {self.PANDevice.vsys_list}")
1454
+
1455
+ self._vsys = value
1456
+ self.entry.update({'vsys': value})
1457
+
1458
+ @staticmethod
1459
+ def validate_member_dict(value: Dict[str, List[str]], attribute_name: str) -> None:
1460
+ if not isinstance(value, dict):
1461
+ raise TypeError(f"{attribute_name} must be a dictionary.")
1462
+
1463
+ if 'member' not in value:
1464
+ raise ValueError(f"Dictionary for {attribute_name} must contain the 'member' key.")
1465
+
1466
+ if not isinstance(value['member'], list):
1467
+ raise TypeError(f"'member' key in {attribute_name} should be associated with a list.")
1468
+
1469
+ if not all(isinstance(item, str) for item in value['member']):
1470
+ raise ValueError(f"All items in the 'member' list of {attribute_name} must be strings.")
1471
+
1472
+ def determine_location(self, kwargs: Dict[str, Any]) -> str:
1473
+ """Determines location based on PANDevice type and kwargs."""
1474
+ if isinstance(self.PANDevice, Panorama):
1475
+ return kwargs.get('device_group', 'shared')
1476
+ elif isinstance(self.PANDevice, Firewall):
1477
+ return kwargs.get('vsys', '')
1478
+ return 'shared'
1479
+
1480
+ @staticmethod
1481
+ def _create_composite_key(location: str, name: str) -> str:
1482
+ """Creates a composite key based on location and name."""
1483
+ return f"{location}:{name}"
1484
+
1485
+ def add_cache(self, pan_object: T) -> None:
1486
+ """Adds an object to the cache if not already present, using a composite key."""
1487
+ # Determine the appropriate location for the composite key
1488
+ location_for_key = pan_object.location if self.location == 'shared' else pan_object.loc
1489
+
1490
+ # Create the composite key using the determined location
1491
+ composite_key = self._create_composite_key(location_for_key, pan_object.name)
1492
+ if composite_key not in self.composite_keys:
1493
+ self.pan_objects[composite_key] = pan_object
1494
+ self.composite_keys.add(composite_key)
1495
+
1496
+ def exists_cache(self, location, name):
1497
+ """Checks if a pan_object exists in the cache based on its composite key."""
1498
+ composite_key = self._create_composite_key(location, name)
1499
+ return composite_key in self.composite_keys
1500
+
1501
+ def get_cache(self, location, name):
1502
+ """Returns the pan_object object for the given location and name if it exists."""
1503
+ composite_key = self._create_composite_key(location, name)
1504
+ return self.pan_objects.get(composite_key)
1505
+
1506
+ def clear_cache(self):
1507
+ """Clears the cache."""
1508
+ self.pan_objects.clear()
1509
+ self.composite_keys.clear()
1510
+
1511
+ def refresh_cache(self):
1512
+ """Fetches all address objects and stores them in the cache."""
1513
+ data = self.get()
1514
+ if data:
1515
+ for entry in data:
1516
+ # Dynamically create an instance of the subclass from which this method was called
1517
+ instance = self.__class__(**entry)
1518
+ self.add_cache(instance)
1519
+
1520
+ def _build_params(self) -> Dict[str, str]:
1521
+ """
1522
+ Builds the parameter dictionary for the API request based on the object's state.
1523
+
1524
+ Returns:
1525
+ Dict[str, str]: The parameters for the API request.
1526
+ """
1527
+ params = {'location': self.location} if self.location else {}
1528
+ if self.name:
1529
+ params['name'] = self.name
1530
+ if self.location in ['template', 'vsys']:
1531
+ params.update({self.location: self.loc})
1532
+ if self.location == 'device-group':
1533
+ params.update({self.location: self.device_group})
1534
+ if type(self).__name__ == 'Zones' and hasattr(self, 'vsys'):
1535
+ params['vsys'] = self.vsys
1536
+
1537
+ return params
1538
+
1539
+ def get(self) -> Optional[List[Dict[str, Any]]]:
1540
+ """
1541
+ Retrieve details for the current tab based on its name and location.
1542
+
1543
+ Returns:
1544
+ Optional[List[Dict[str, Any]]]: A list of entries from the API response or None if an error occurs.
1545
+ """
1546
+ params = self._build_params()
1547
+ try:
1548
+ response = self.rest_request('GET', params=params)
1549
+ except requests.exceptions.RequestException as e:
1550
+ logging.error(f'Failed to get {self.__class__.__name__}: {e}')
1551
+ raise Exception(f'Network request failed: {e}') from e
1552
+
1553
+ if response.get('@status') != 'success':
1554
+ logging.error(f'API returned error: {response.get("message", "Unknown error")}')
1555
+ return None
1556
+
1557
+ return response.get('result', {}).get('entry')
1558
+
1559
+ def get_all(self) -> List[Dict[str, Any]]:
1560
+ """
1561
+ Get a list of all elements in this section.
1562
+
1563
+ Returns:
1564
+ List[Dict[str, Any]]: A list of entries from the API response.
1565
+
1566
+ Raises:
1567
+ Exception: If the API request fails or returns an error status.
1568
+ """
1569
+ params = self._build_params()
1570
+
1571
+ try:
1572
+ response = self.rest_request('GET', params=params)
1573
+ except requests.exceptions.RequestException as e:
1574
+ logging.error(f'Network request failed: {e}')
1575
+ raise Exception(f'Network request failed: {e}') from e
1576
+
1577
+ if response.get('@status') != 'success':
1578
+ error_message = response.get('message', 'Unknown error')
1579
+ logging.error(error_message)
1580
+ raise Exception(f'API request failed: {error_message}')
1581
+
1582
+ return response.get('result', {}).get('entry', [])
1583
+
1584
+ def create(self) -> bool:
1585
+ """
1586
+ Attempts to create a new entry in the network configuration.
1587
+
1588
+ Returns:
1589
+ bool: True if the entry was successfully created, False otherwise.
1590
+
1591
+ Raises:
1592
+ NetworkRequestError: If there's an issue with the network request.
1593
+ APIResponseError: If the API returns a non-success status.
1594
+ """
1595
+ params = self._build_params()
1596
+ data = {'entry': self.entry}
1597
+ try:
1598
+ response = self.rest_request('POST', params=params, json=data)
1599
+ except requests.exceptions.RequestException as e:
1600
+ logging.error(f'Failed to create {self.__class__.__name__}: {e}')
1601
+ raise NetworkRequestError(f'Network request failed for {self.PANDevice.base_url}', {'error': str(e)})
1602
+
1603
+ if response.get('@status') != 'success':
1604
+ logging.error(f'API returned error during create: message: {response.get("message", "Unknown error")}, details: {response.get("details", "Unknown error")}')
1605
+ raise APIResponseError('API request did not return success during create', response)
1606
+
1607
+ return True
1608
+
1609
+ def edit(self) -> bool:
1610
+ """
1611
+ Attempts to edit an existing entry in the network configuration.
1612
+
1613
+ Returns:
1614
+ bool: True if the entry was successfully edited.
1615
+
1616
+ Raises:
1617
+ NetworkRequestError: If there's an issue with the network request.
1618
+ APIResponseError: If the API returns a non-success status.
1619
+ """
1620
+ params = self._build_params()
1621
+ data = {'entry': self.entry}
1622
+ try:
1623
+ # Log request details for debugging
1624
+ logging.debug(
1625
+ f'Attempting to edit {self.__class__.__name__}. URL: {self.base_url}, Params: {params}, Data: {data}')
1626
+ response = self.rest_request('PUT', params=params, json=data)
1627
+
1628
+ logging.debug(f'Received response: {response}')
1629
+ except requests.exceptions.HTTPError as e:
1630
+ # Log and raise a detailed network error
1631
+ logging.error(f'Network request failed for {self.base_url}. Exception: {e}')
1632
+ # Additional details for debugging
1633
+ raise NetworkRequestError(
1634
+ f"Network request failed for {self.base_url}",
1635
+ {
1636
+ 'error': str(e),
1637
+ 'url': self.base_url,
1638
+ 'params': params,
1639
+ 'data': data
1640
+ }
1641
+ )
1642
+
1643
+ # Check response status
1644
+ if response.get('@status') != 'success':
1645
+ # Log detailed error about the API response
1646
+ logging.error(f"API error during edit. Params: {params}, Data: {data}, Response: {response}")
1647
+ raise APIResponseError(
1648
+ "API request did not return success during edit",
1649
+ {
1650
+ 'response': response,
1651
+ 'params': params,
1652
+ 'data': data
1653
+ }
1654
+ )
1655
+
1656
+ logging.debug(f'Edit successful for {self.__class__.__name__}: {self.entry}')
1657
+ return True
1658
+
1659
+ def delete(self) -> bool:
1660
+ """
1661
+ Attempts to delete an existing entry in the network configuration.
1662
+
1663
+ Returns:
1664
+ bool: True if the entry was successfully deleted.
1665
+
1666
+ Raises:
1667
+ NetworkRequestError: If there's an issue with the network request.
1668
+ APIResponseError: If the API returns a non-success status.
1669
+ """
1670
+ params = self._build_params()
1671
+
1672
+ try:
1673
+ response = self.rest_request('DELETE', params=params)
1674
+ except requests.exceptions.RequestException as e:
1675
+ logging.error(f'Failed to delete {self.__class__.__name__}: {e}')
1676
+ raise NetworkRequestError(f'Network request failed for {self.base_url}', {'error': str(e)})
1677
+
1678
+ if response.get('@status') != 'success':
1679
+ logging.error(f'API returned error during delete: {data.get("message", "Unknown error")}')
1680
+ raise APIResponseError('API request did not return success during delete', response)
1681
+
1682
+ return True
1683
+
1684
+ def rename(self, newname: str) -> bool:
1685
+ """
1686
+ Attempts to rename an existing entry in the network configuration.
1687
+
1688
+ Parameters:
1689
+ newname (str): The new name for the item.
1690
+
1691
+ Returns:
1692
+ bool: True if the item was successfully renamed.
1693
+
1694
+ Raises:
1695
+ ValueError: If the new name does not meet validation criteria.
1696
+ NetworkRequestError: If there's an issue with the network request.
1697
+ APIResponseError: If the API returns a non-success status.
1698
+ """
1699
+ # Validate the new name
1700
+ if not self.valid_name(newname, self.max_name_length):
1701
+ raise ValueError(f'Invalid new name: {newname}')
1702
+
1703
+ params = self._build_params()
1704
+ params.update({'newname': newname})
1705
+
1706
+ try:
1707
+ response = self.rest_request('POST', params=params)
1708
+ except requests.exceptions.RequestException as e:
1709
+ logging.error(f'Failed to rename {self.__class__.__name__} {self.name} to {newname}: {e}')
1710
+ raise NetworkRequestError(f'Network request failed for {self.base_url}', {'error': str(e)})
1711
+
1712
+ if response.get('@status') != 'success':
1713
+ logging.error(f'API returned error during rename: {response.get("message", "Unknown error")}')
1714
+ raise APIResponseError('API request did not return success during rename', response)
1715
+
1716
+ return True
1717
+
1718
+ def refresh(self) -> bool:
1719
+ """
1720
+ Retrieves live data from a device and updates the instance attributes based on the data.
1721
+ Ensures that only one entry is returned from the data retrieval call and dynamically sets
1722
+ the instance attributes based on the data keys, modifying them if necessary.
1723
+
1724
+ :return: True if the refresh is successful and instance attributes are updated, False otherwise.
1725
+ """
1726
+ if not self.name:
1727
+ logger.error('The name attribute must be available to do a refresh.')
1728
+ return False
1729
+
1730
+ entry: List[Dict[str, Any]] = self.get()
1731
+ if not entry:
1732
+ return False
1733
+
1734
+ if len(entry) > 1:
1735
+ error_message: str = 'More than one entry returned; cannot refresh.'
1736
+ logger.error(error_message)
1737
+ raise ValueError(error_message)
1738
+
1739
+ updated = False
1740
+ for key, value in entry[0].items():
1741
+ if key == '@name':
1742
+ setattr(self, 'name', value)
1743
+ updated = True
1744
+ continue
1745
+
1746
+ # need to replace any - with _ so it can be used as an attribute
1747
+ modified_key: str = key.replace('-', '_')
1748
+ # Append an underscore if the key is a Python built-in name
1749
+ if modified_key in dir(builtins):
1750
+ modified_key += '_'
1751
+
1752
+ if modified_key.startswith('@'):
1753
+ setattr(self, modified_key.lstrip('@'), value)
1754
+ updated = True
1755
+ continue
1756
+ # Check if the attribute exists in the instance before setting it
1757
+ if hasattr(self, modified_key):
1758
+ setattr(self, modified_key, value)
1759
+ updated = True
1760
+ else:
1761
+ # If the key doesn't match any attribute, update self.entry directly
1762
+ self.entry[key] = value
1763
+ updated = True
1764
+
1765
+ return updated
1766
+
1767
+
1768
+
1769
+
1770
+
1771
+
1772
+