illumio-pylo 0.3.12__py3-none-any.whl → 0.3.13__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.
@@ -28,10 +28,6 @@ from typing import Union, Dict, Any, List, Optional, Literal
28
28
 
29
29
  requests.packages.urllib3.disable_warnings()
30
30
 
31
- objects_types_strings = Literal[
32
- 'workloads', 'virtual_services', 'labels', 'labelgroups', 'iplists', 'services',
33
- 'rulesets', 'security_principals', 'label_dimensions']
34
-
35
31
  default_retry_count_if_api_call_limit_reached = 3
36
32
  default_retry_wait_time_if_api_call_limit_reached = 10
37
33
  default_max_objects_for_sync_calls = 200000
@@ -49,7 +45,7 @@ def get_field_or_die(field_name: str, data):
49
45
 
50
46
 
51
47
  ObjectTypes = Literal['iplists', 'workloads', 'virtual_services', 'labels', 'labelgroups', 'services', 'rulesets',
52
- 'security_principals', 'label_dimensions']
48
+ 'security_principals', 'label_dimensions']
53
49
 
54
50
  all_object_types: Dict[ObjectTypes, ObjectTypes] = {
55
51
  'iplists': 'iplists',
@@ -316,7 +312,7 @@ class APIConnector:
316
312
  or\
317
313
  method == 'DELETE' and req.status_code != 204 \
318
314
  or \
319
- method == 'PUT' and req.status_code != 204 and req.status_code != 200:
315
+ method == 'PUT' and req.status_code != 204 and req.status_code != 202 and req.status_code != 200:
320
316
 
321
317
  if req.status_code == 429:
322
318
  # too many requests sent in short amount of time? [{"token":"too_many_requests_error", ....}]
@@ -371,7 +367,7 @@ class APIConnector:
371
367
  self.collect_pce_infos()
372
368
  return self.version_string
373
369
 
374
- def get_objects_count_by_type(self, object_type: objects_types_strings) -> int:
370
+ def get_objects_count_by_type(self, object_type: ObjectTypes) -> int:
375
371
 
376
372
  def extract_count(headers):
377
373
  count = headers.get('x-total-count')
@@ -1323,12 +1319,23 @@ class APIConnector:
1323
1319
 
1324
1320
  raise pylo.PyloObjectNotFound("Request with ID {} not found".format(request_href))
1325
1321
 
1326
- def explorer_search(self, filters: Union[Dict, 'pylo.ExplorerFilterSetV1'],
1327
- max_running_time_seconds=1800,
1328
- check_for_update_interval_seconds=10) -> 'pylo.ExplorerResultSetV1':
1322
+ def explorer_search(self, filters: Union[Dict, 'pylo.ExplorerFilterSetV1', 'pylo.ExplorerFilterSetV2'],
1323
+ max_running_time_seconds=1800,check_for_update_interval_seconds=10, draft_mode_enabled=False)\
1324
+ -> Union['pylo.ExplorerResultSetV1', 'pylo.ExplorerResultSetV2']:
1325
+ """
1326
+
1327
+ :param filters:
1328
+ :param max_running_time_seconds:
1329
+ :param check_for_update_interval_seconds:
1330
+ :param draft_mode_enabled: only for V2 filters
1331
+ :return:
1332
+ """
1333
+
1329
1334
  path = "/traffic_flows/async_queries"
1330
1335
  if isinstance(filters, pylo.ExplorerFilterSetV1):
1331
1336
  data = filters.generate_json_query()
1337
+ elif isinstance(filters, pylo.ExplorerFilterSetV2):
1338
+ data = filters.generate_json_query()
1332
1339
  else:
1333
1340
  data = filters
1334
1341
 
@@ -1373,11 +1380,45 @@ class APIConnector:
1373
1380
  if query_status is None:
1374
1381
  raise pylo.PyloEx("Unexpected logic where query_status is None", query_queued_json_response)
1375
1382
 
1376
- query_json_response = self.do_get_call(query_href + "/download", json_output_expected=True, include_org_id=False)
1383
+ # if draft mode is not enabled we can download the results and pass them over
1384
+ if not draft_mode_enabled or isinstance(filters, pylo.ExplorerFilterSetV1):
1385
+ query_json_response = self.do_get_call(query_href + "/download", json_output_expected=True, include_org_id=False)
1386
+
1387
+ if isinstance(filters, pylo.ExplorerFilterSetV1):
1388
+ result = pylo.ExplorerResultSetV1(query_json_response,
1389
+ owner=self,
1390
+ emulated_process_exclusion=filters.exclude_processes_emulate)
1391
+ else:
1392
+ result = pylo.ExplorerResultSetV2(query_json_response)
1393
+
1394
+ return result
1395
+
1396
+ # from here we are in draft mode with V2 filters so we must request API to calculate the draft results
1397
+ draft_mode_trigger_url = query_href + "/update_rules?label_based_rules=false&offset=0&limit=250000"
1398
+ draft_mode_trigger_response = self.do_put_call(draft_mode_trigger_url, json_output_expected=False, include_org_id=False)
1399
+
1400
+ time.sleep(5) # wait a bit before checking for results
1377
1401
 
1378
- result = pylo.ExplorerResultSetV1(query_json_response,
1379
- owner=self,
1380
- emulated_process_exclusion=filters.exclude_processes_emulate)
1402
+ while True:
1403
+ # check that we don't wait too long
1404
+ if time.time() - start_time > max_running_time_seconds:
1405
+ raise pylo.PyloApiEx("Timeout while waiting for draft mode results to be calculated", draft_mode_trigger_response)
1406
+
1407
+ draft_mode_status_response = self.explorer_async_query_get_specific_request_status(query_href)
1408
+ if draft_mode_status_response['rules'] == "completed":
1409
+ query_status = draft_mode_status_response
1410
+ break
1411
+
1412
+ if draft_mode_status_response['rules'] not in ["queued", "working"]:
1413
+ raise pylo.PyloApiEx("Draft mode results calculation failed with status {}".format(draft_mode_status_response['status']),
1414
+ draft_mode_status_response)
1415
+
1416
+ time.sleep(check_for_update_interval_seconds)
1417
+
1418
+ if query_status is None:
1419
+ raise pylo.PyloEx("Unexpected logic where query_status is None", query_queued_json_response)
1420
+ query_json_response = self.do_get_call(query_href + "/download", json_output_expected=True, include_org_id=False)
1421
+ result = pylo.ExplorerResultSetV2(query_json_response)
1381
1422
 
1382
1423
  return result
1383
1424
 
@@ -1407,6 +1448,12 @@ class APIConnector:
1407
1448
  check_for_update_interval_seconds: int = 10) -> 'pylo.ExplorerQuery':
1408
1449
  return pylo.ExplorerQuery(self, max_results, max_running_time_seconds, check_for_update_interval_seconds)
1409
1450
 
1451
+ def new_explorer_query_v2(self, max_results: int = 2500, draft_mode_enabled=False, max_running_time_seconds: int = 1800,
1452
+ check_for_update_interval_seconds: int = 10) -> 'pylo.ExplorerQueryV2':
1453
+ return pylo.ExplorerQueryV2(self, max_results=max_results, draft_mode_enabled=draft_mode_enabled,
1454
+ max_running_time_seconds=max_running_time_seconds,
1455
+ check_for_update_interval_seconds=check_for_update_interval_seconds)
1456
+
1410
1457
  def new_audit_log_query(self, max_results: int = 10000, max_running_time_seconds: int = 1800,
1411
1458
  check_for_update_interval_seconds: int = 10) -> 'pylo.AuditLogQuery':
1412
1459
  return pylo.AuditLogQuery(self, max_results, max_running_time_seconds)
@@ -46,6 +46,10 @@ class CredentialProfile:
46
46
 
47
47
  self.raw_json: Optional[CredentialFileEntry] = None
48
48
 
49
+ def is_api_key_encrypted(self) -> bool:
50
+ """Check if the API key is encrypted (starts with $encrypted$:)."""
51
+ return self.api_key.startswith("$encrypted$:")
52
+
49
53
  @staticmethod
50
54
  def from_credentials_file_entry(credential_file_entry: CredentialFileEntry, originating_file: Optional[str] = None):
51
55
  return CredentialProfile(credential_file_entry['name'],
@@ -57,11 +61,104 @@ class CredentialProfile:
57
61
  credential_file_entry['verify_ssl'],
58
62
  originating_file)
59
63
 
64
+ @staticmethod
65
+ def from_environment_variables() -> 'CredentialProfile':
66
+ """
67
+ Create a CredentialProfile from environment variables.
68
+ This profile is only accessible when specifically requesting profile name 'ENV'.
69
+
70
+ Required environment variables:
71
+ - PYLO_FQDN: Fully qualified domain name of the PCE
72
+ - PYLO_API_USER: API username
73
+ - PYLO_API_KEY: API key (can be encrypted with $encrypted$: prefix)
74
+
75
+ Optional environment variables:
76
+ - PYLO_PORT: Port number (default: 8443, or 443 for illum.io domains)
77
+ - PYLO_ORG_ID: Organization ID (default: 1, required for illum.io domains)
78
+ - PYLO_VERIFY_SSL: Verify SSL certificate (default: true, accepts: true/false/1/0)
79
+
80
+ :return: CredentialProfile with name='ENV'
81
+ :raises PyloEx: If required environment variables are missing or invalid
82
+ """
83
+ # Check for required environment variables
84
+ fqdn = os.environ.get('PYLO_FQDN')
85
+ api_user = os.environ.get('PYLO_API_USER')
86
+ api_key = os.environ.get('PYLO_API_KEY')
87
+
88
+ missing_vars = []
89
+ if not fqdn:
90
+ missing_vars.append('PYLO_FQDN')
91
+ if not api_user:
92
+ missing_vars.append('PYLO_API_USER')
93
+ if not api_key:
94
+ missing_vars.append('PYLO_API_KEY')
95
+
96
+ if missing_vars:
97
+ raise PyloEx("Missing required environment variables for ENV profile: {}. "
98
+ "Required: PYLO_FQDN, PYLO_API_USER, PYLO_API_KEY. "
99
+ "Optional: PYLO_PORT, PYLO_ORG_ID, PYLO_VERIFY_SSL".format(', '.join(missing_vars)))
100
+
101
+ # Determine if this is an illum.io domain
102
+ is_illumio_domain = 'illum.io' in fqdn.lower()
103
+
104
+ # Parse PORT with validation
105
+ port_str = os.environ.get('PYLO_PORT')
106
+ if port_str:
107
+ try:
108
+ port = int(port_str)
109
+ if port <= 0 or port > 65535:
110
+ raise ValueError("Port must be between 1 and 65535")
111
+ except ValueError as e:
112
+ raise PyloEx("Invalid PYLO_PORT value '{}': must be a valid port number (1-65535)".format(port_str))
113
+ else:
114
+ # Default port based on domain type
115
+ port = 443 if is_illumio_domain else 8443
116
+
117
+ # Parse ORG_ID with validation
118
+ org_id_str = os.environ.get('PYLO_ORG_ID')
119
+ if org_id_str:
120
+ try:
121
+ org_id = int(org_id_str)
122
+ if org_id <= 0:
123
+ raise ValueError("Organization ID must be positive")
124
+ except ValueError as e:
125
+ raise PyloEx("Invalid PYLO_ORG_ID value '{}': must be a positive integer".format(org_id_str))
126
+ else:
127
+ # For illum.io domains, org_id is mandatory
128
+ if is_illumio_domain:
129
+ raise PyloEx("PYLO_ORG_ID is required for illum.io domains (no default available)")
130
+ org_id = 1
131
+
132
+ # Parse VERIFY_SSL with validation
133
+ verify_ssl_str = os.environ.get('PYLO_VERIFY_SSL', 'true').lower()
134
+ if verify_ssl_str in ['true', '1', 'yes', 'y']:
135
+ verify_ssl = True
136
+ elif verify_ssl_str in ['false', '0', 'no', 'n']:
137
+ verify_ssl = False
138
+ else:
139
+ raise PyloEx("Invalid PYLO_VERIFY_SSL value '{}': must be true/false/1/0/yes/no/y/n (case-insensitive)".format(verify_ssl_str))
140
+
141
+ # Decrypt API key if encrypted
142
+ if api_key.startswith("$encrypted$:"):
143
+ log.debug("Detected encrypted API key in environment variable, attempting to decrypt")
144
+ api_key = decrypt_api_key(api_key)
145
+
146
+ return CredentialProfile(
147
+ name='ENV',
148
+ fqdn=fqdn,
149
+ port=port,
150
+ api_user=api_user,
151
+ api_key=api_key,
152
+ org_id=org_id,
153
+ verify_ssl=verify_ssl,
154
+ originating_file='environment'
155
+ )
156
+
60
157
 
61
158
  CredentialsFileType = Union[CredentialFileEntry | List[CredentialFileEntry]]
62
159
 
63
160
 
64
- def check_profile_json_structure(profile: Dict) -> None:
161
+ def check_profile_json_structure(profile: CredentialFileEntry) -> None:
65
162
  # ensure all fields from CredentialFileEntry are present
66
163
  if "name" not in profile or type(profile["name"]) != str:
67
164
  raise PyloEx("The profile {} does not contain a name".format(profile))
@@ -79,7 +176,7 @@ def check_profile_json_structure(profile: Dict) -> None:
79
176
  raise PyloEx("The profile {} does not contain a verify_ssl".format(profile))
80
177
 
81
178
 
82
- def get_all_credentials_from_file(credential_file: str ) -> List[CredentialProfile]:
179
+ def get_all_credentials_from_file(credential_file: str) -> List[CredentialProfile]:
83
180
  log.debug("Loading credentials from file: {}".format(credential_file))
84
181
  with open(credential_file, 'r') as f:
85
182
  credentials: CredentialsFileType = json.load(f)
@@ -97,11 +194,26 @@ def get_all_credentials_from_file(credential_file: str ) -> List[CredentialProfi
97
194
 
98
195
  def get_credentials_from_file(fqdn_or_profile_name: str = None,
99
196
  credential_file: str = None, fail_with_an_exception=True) -> Optional[CredentialProfile]:
197
+ """
198
+ Get credentials from a file or environment variables.
100
199
 
200
+ Special profile name 'ENV' will load credentials from environment variables instead of files.
201
+ See CredentialProfile.from_environment_variables() for required environment variables.
202
+
203
+ :param fqdn_or_profile_name: Profile name or FQDN to search for (default: 'default')
204
+ :param credential_file: Specific credential file to search in (optional)
205
+ :param fail_with_an_exception: Raise exception if profile not found (default: True)
206
+ :return: CredentialProfile or None
207
+ """
101
208
  if fqdn_or_profile_name is None:
102
209
  log.debug("No fqdn_or_profile_name provided, profile_name=default will be used")
103
210
  fqdn_or_profile_name = "default"
104
211
 
212
+ # Check for special 'ENV' profile name to load from environment variables
213
+ if fqdn_or_profile_name.lower() == 'env':
214
+ log.debug("Loading credentials from environment variables")
215
+ return CredentialProfile.from_environment_variables()
216
+
105
217
  credential_files: List[str] = []
106
218
  if credential_file is not None:
107
219
  credential_files.append(credential_file)
@@ -170,17 +282,21 @@ def create_credential_in_file(file_full_path: str, data: CredentialFileEntry, ov
170
282
  credentials: CredentialsFileType = json.load(f)
171
283
  if isinstance(credentials, list):
172
284
  # check if the profile already exists
285
+ profile_found = False
173
286
  for profile in credentials:
174
287
  if profile['name'].lower() == data['name'].lower():
175
288
  if overwrite_existing_profile:
176
289
  # profile is a dict, remove of all its entries
177
290
  for key in list(profile.keys()):
291
+ # noinspection PyTypedDict
178
292
  del profile[key]
179
293
  profile.update(data)
294
+ profile_found = True
180
295
  break
181
296
  else:
182
297
  raise PyloEx("Profile with name {} already exists in file {}".format(data['name'], file_full_path))
183
- credentials.append(data)
298
+ if not profile_found:
299
+ credentials.append(data)
184
300
  else:
185
301
  if data['name'].lower() == credentials['name'].lower():
186
302
  if overwrite_existing_profile:
@@ -362,3 +478,14 @@ def is_encryption_available() -> bool:
362
478
  return False
363
479
 
364
480
 
481
+ def is_env_credentials_available() -> bool:
482
+ """
483
+ Check if required environment variables for ENV profile are set.
484
+
485
+ :return: True if PYLO_FQDN, PYLO_API_USER, and PYLO_API_KEY are all set, False otherwise
486
+ """
487
+ return (os.environ.get('PYLO_FQDN') is not None and
488
+ os.environ.get('PYLO_API_USER') is not None and
489
+ os.environ.get('PYLO_API_KEY') is not None)
490
+
491
+