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.
- illumio_pylo/API/APIConnector.py +61 -14
- illumio_pylo/API/CredentialsManager.py +130 -3
- illumio_pylo/API/Explorer.py +619 -14
- illumio_pylo/API/JsonPayloadTypes.py +64 -4
- illumio_pylo/FilterQuery.py +892 -0
- illumio_pylo/LabelCommon.py +13 -3
- illumio_pylo/LabelDimension.py +109 -0
- illumio_pylo/LabelStore.py +97 -38
- illumio_pylo/WorkloadStore.py +58 -0
- illumio_pylo/__init__.py +9 -3
- illumio_pylo/cli/__init__.py +5 -2
- illumio_pylo/cli/commands/__init__.py +1 -0
- illumio_pylo/cli/commands/credential_manager.py +176 -0
- illumio_pylo/cli/commands/traffic_export.py +358 -0
- illumio_pylo/cli/commands/ui/credential_manager_ui/app.js +191 -2
- illumio_pylo/cli/commands/ui/credential_manager_ui/index.html +50 -1
- illumio_pylo/cli/commands/ui/credential_manager_ui/styles.css +179 -28
- illumio_pylo/cli/commands/update_pce_objects_cache.py +1 -2
- illumio_pylo/cli/commands/workload_export.py +29 -0
- {illumio_pylo-0.3.12.dist-info → illumio_pylo-0.3.13.dist-info}/METADATA +1 -1
- {illumio_pylo-0.3.12.dist-info → illumio_pylo-0.3.13.dist-info}/RECORD +24 -22
- illumio_pylo/Query.py +0 -331
- {illumio_pylo-0.3.12.dist-info → illumio_pylo-0.3.13.dist-info}/WHEEL +0 -0
- {illumio_pylo-0.3.12.dist-info → illumio_pylo-0.3.13.dist-info}/licenses/LICENSE +0 -0
- {illumio_pylo-0.3.12.dist-info → illumio_pylo-0.3.13.dist-info}/top_level.txt +0 -0
illumio_pylo/API/APIConnector.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
+
|