patcherctl 1.3.1__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.
- Patcher/__about__.py +2 -0
- Patcher/__init__.py +0 -0
- Patcher/cli.py +130 -0
- Patcher/client/__init__.py +0 -0
- Patcher/client/api_client.py +291 -0
- Patcher/client/config_manager.py +90 -0
- Patcher/client/report_manager.py +307 -0
- Patcher/client/token_manager.py +173 -0
- Patcher/client/ui_manager.py +104 -0
- Patcher/exceptions.py +249 -0
- Patcher/logger.py +75 -0
- Patcher/model/__init__.py +0 -0
- Patcher/model/excel_report.py +63 -0
- Patcher/model/models.py +150 -0
- Patcher/model/pdf_report.py +118 -0
- Patcher/wrappers.py +215 -0
- patcherctl-1.3.1.dist-info/LICENSE.txt +201 -0
- patcherctl-1.3.1.dist-info/METADATA +309 -0
- patcherctl-1.3.1.dist-info/RECORD +22 -0
- patcherctl-1.3.1.dist-info/WHEEL +5 -0
- patcherctl-1.3.1.dist-info/entry_points.txt +2 -0
- patcherctl-1.3.1.dist-info/top_level.txt +1 -0
Patcher/__about__.py
ADDED
Patcher/__init__.py
ADDED
|
File without changes
|
Patcher/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import asyncclick as click
|
|
2
|
+
import asyncio
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from typing import AnyStr, Optional
|
|
7
|
+
from src.Patcher.__about__ import __version__
|
|
8
|
+
|
|
9
|
+
from src.Patcher.wrappers import first_run
|
|
10
|
+
from src.Patcher.client.config_manager import ConfigManager
|
|
11
|
+
from src.Patcher.client.ui_manager import UIConfigManager
|
|
12
|
+
from src.Patcher.client.token_manager import TokenManager
|
|
13
|
+
from src.Patcher.client.api_client import ApiClient
|
|
14
|
+
from src.Patcher.client.report_manager import ReportManager
|
|
15
|
+
from src.Patcher.model.excel_report import ExcelReport
|
|
16
|
+
from src.Patcher.model.pdf_report import PDFReport
|
|
17
|
+
|
|
18
|
+
DATE_FORMATS = {
|
|
19
|
+
"Month-Year": "%B %Y", # April 2024
|
|
20
|
+
"Month-Day-Year": "%B %d %Y", # April 21 2024
|
|
21
|
+
"Year-Month-Day": "%Y %B %d", # 2024 April 21
|
|
22
|
+
"Day-Month-Year": "%d %B %Y", # 16 April 2024
|
|
23
|
+
"Full": "%A %B %d %Y", # Thursday September 26 2013
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def animate_search(stop_event: threading.Event, enable_animation: bool) -> None:
|
|
28
|
+
"""Animates ellipsis in 'Processing...' message."""
|
|
29
|
+
if not enable_animation:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
i = 0
|
|
33
|
+
max_length = 0
|
|
34
|
+
while not stop_event.is_set():
|
|
35
|
+
message = "\rProcessing" + "." * (i % 4)
|
|
36
|
+
max_length = max(max_length, len(message))
|
|
37
|
+
click.echo(message, nl=False)
|
|
38
|
+
i += 1
|
|
39
|
+
time.sleep(0.5)
|
|
40
|
+
|
|
41
|
+
# Clear animation line after stopping
|
|
42
|
+
click.echo("\r" + " " * max_length + "\r", nl=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command()
|
|
46
|
+
@click.version_option(version=__version__)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--path", "-p", type=click.Path(), required=True, help="Path to save the report"
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--pdf",
|
|
52
|
+
"-f",
|
|
53
|
+
is_flag=True,
|
|
54
|
+
help="Generate a PDF report along with Excel spreadsheet",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--sort",
|
|
58
|
+
"-s",
|
|
59
|
+
type=click.STRING,
|
|
60
|
+
required=False,
|
|
61
|
+
help="Sort patch reports by a specified column.",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--omit",
|
|
65
|
+
"-o",
|
|
66
|
+
is_flag=True,
|
|
67
|
+
help="Omit software titles with patches released in last 48 hours",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--date-format",
|
|
71
|
+
"-d",
|
|
72
|
+
type=click.Choice(list(DATE_FORMATS.keys()), case_sensitive=False),
|
|
73
|
+
default="Month-Day-Year",
|
|
74
|
+
help="Specify the date format for the PDF header from predefined choices.",
|
|
75
|
+
)
|
|
76
|
+
@click.option(
|
|
77
|
+
"--ios",
|
|
78
|
+
"-m",
|
|
79
|
+
is_flag=True,
|
|
80
|
+
help="Include the amount of enrolled mobile devices on the latest version of their respective OS.",
|
|
81
|
+
)
|
|
82
|
+
@click.option(
|
|
83
|
+
"--concurrency",
|
|
84
|
+
type=click.INT,
|
|
85
|
+
default=5,
|
|
86
|
+
help="Set the maximum concurrency level for API calls.",
|
|
87
|
+
)
|
|
88
|
+
@click.option(
|
|
89
|
+
"--debug",
|
|
90
|
+
"-x",
|
|
91
|
+
is_flag=True,
|
|
92
|
+
default=False,
|
|
93
|
+
help="Enable debug logging to see detailed debug messages.",
|
|
94
|
+
)
|
|
95
|
+
@first_run
|
|
96
|
+
async def main(
|
|
97
|
+
path: AnyStr,
|
|
98
|
+
pdf: bool,
|
|
99
|
+
sort: Optional[AnyStr],
|
|
100
|
+
omit: bool,
|
|
101
|
+
date_format: AnyStr,
|
|
102
|
+
ios: bool,
|
|
103
|
+
concurrency: int,
|
|
104
|
+
debug: bool,
|
|
105
|
+
) -> None:
|
|
106
|
+
config = ConfigManager()
|
|
107
|
+
token_manager = TokenManager(config)
|
|
108
|
+
api_client = ApiClient(config)
|
|
109
|
+
excel_report = ExcelReport(config)
|
|
110
|
+
ui_config = UIConfigManager()
|
|
111
|
+
pdf_report = PDFReport(ui_config)
|
|
112
|
+
api_client.jamf_client.set_max_concurrency(concurrency=concurrency)
|
|
113
|
+
|
|
114
|
+
patcher = ReportManager(
|
|
115
|
+
config, token_manager, api_client, excel_report, pdf_report, ui_config, debug
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
actual_format = DATE_FORMATS[date_format]
|
|
119
|
+
stop_event = threading.Event()
|
|
120
|
+
enable_animation = not debug
|
|
121
|
+
animation_thread = threading.Thread(
|
|
122
|
+
target=animate_search, args=(stop_event, enable_animation)
|
|
123
|
+
)
|
|
124
|
+
animation_thread.start()
|
|
125
|
+
|
|
126
|
+
await patcher.process_reports(path, pdf, sort, omit, ios, stop_event, actual_format)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
asyncio.run(main())
|
|
File without changes
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import asyncio
|
|
3
|
+
import subprocess
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import AnyStr, Optional, Dict, List
|
|
7
|
+
from src.Patcher import logger
|
|
8
|
+
from src.Patcher.client.token_manager import TokenManager
|
|
9
|
+
from src.Patcher.client.config_manager import ConfigManager
|
|
10
|
+
from src.Patcher.wrappers import check_token
|
|
11
|
+
|
|
12
|
+
logthis = logger.setup_child_logger("ApiClient", __name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiClient:
|
|
16
|
+
"""Provides methods for interacting with the Jamf API."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: ConfigManager):
|
|
19
|
+
"""
|
|
20
|
+
Initializes the ApiClient with the provided ConfigManager.
|
|
21
|
+
|
|
22
|
+
:param config: Instance of ConfigManager for loading and storing credentials.
|
|
23
|
+
:type config: ConfigManager
|
|
24
|
+
:raises ValueError: If the JamfClient configuration is invalid.
|
|
25
|
+
"""
|
|
26
|
+
self.log = logthis
|
|
27
|
+
self.config = config
|
|
28
|
+
self.jamf_client = config.attach_client()
|
|
29
|
+
if self.jamf_client:
|
|
30
|
+
self.token = self.jamf_client.token
|
|
31
|
+
self.log.info("JamfClient and token successfully attached")
|
|
32
|
+
else:
|
|
33
|
+
self.log.error("Invalid JamfClient configuration detected!")
|
|
34
|
+
raise ValueError("Invalid JamfClient configuration detected!")
|
|
35
|
+
self.jamf_url = self.jamf_client.base_url
|
|
36
|
+
self.headers = {
|
|
37
|
+
"Accept": "application/json",
|
|
38
|
+
"Authorization": f"Bearer {self.token}",
|
|
39
|
+
}
|
|
40
|
+
self.token_manager = TokenManager(config)
|
|
41
|
+
self.max_concurrency = self.jamf_client.max_concurrency
|
|
42
|
+
self.log.debug("Initializing ApiClient")
|
|
43
|
+
# self.connector = aiohttp.TCPConnector(limit=self.jamf_client.max_concurrency)
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def convert_timezone(utc_time_str: AnyStr) -> Optional[AnyStr]:
|
|
47
|
+
"""
|
|
48
|
+
Converts a UTC time string to a formatted string without timezone information.
|
|
49
|
+
|
|
50
|
+
:param utc_time_str: UTC time string in ISO 8601 format.
|
|
51
|
+
:type utc_time_str: AnyStr
|
|
52
|
+
:return: Formatted time string or error message.
|
|
53
|
+
:rtype: AnyStr
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
utc_time = datetime.strptime(utc_time_str, "%Y-%m-%dT%H:%M:%S%z")
|
|
57
|
+
time_str = utc_time.strftime("%b %d %Y")
|
|
58
|
+
return time_str
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
logthis.error(f"Invalid time format provided. Details: {e}")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
async def fetch_json(
|
|
64
|
+
self, url: AnyStr, session: aiohttp.ClientSession
|
|
65
|
+
) -> Optional[Dict]:
|
|
66
|
+
"""
|
|
67
|
+
Asynchronously fetches JSON data from a specified URL using a session.
|
|
68
|
+
|
|
69
|
+
:param url: URL to fetch the JSON data from.
|
|
70
|
+
:type url: AnyStr
|
|
71
|
+
:param session: Async session used to make the request, instance of aiohttp.ClientSession.
|
|
72
|
+
:type session: aiohttp.ClientSession
|
|
73
|
+
:return: JSON data as a dictionary or an empty dictionary on error.
|
|
74
|
+
:rtype: Optional[Dict]
|
|
75
|
+
"""
|
|
76
|
+
logthis.debug(f"Fetching JSON data from URL: {url}")
|
|
77
|
+
try:
|
|
78
|
+
async with session.get(url, headers=self.headers) as response:
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
json_data = await response.json()
|
|
81
|
+
self.log.info(f"Successfully fetched JSON data from {url}")
|
|
82
|
+
return json_data
|
|
83
|
+
except aiohttp.ClientResponseError as e:
|
|
84
|
+
self.log.error(
|
|
85
|
+
f"Received a client error while fetching JSON from {url}: {e}"
|
|
86
|
+
)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
self.log.error(f"Error fetching JSON: {e}")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
async def fetch_batch(self, urls: List[AnyStr]) -> List[Optional[Dict]]:
|
|
92
|
+
"""
|
|
93
|
+
Fetches JSON data in batches to respect the concurrency limit. Data is fetched
|
|
94
|
+
from each URL in the provided list, ensuring that no more than `max_concurrency`
|
|
95
|
+
requests are sent concurrently.
|
|
96
|
+
|
|
97
|
+
:param urls: A list of URLs to fetch JSON data from
|
|
98
|
+
:type urls: List[AnyStr]
|
|
99
|
+
:return: A list of dictionaries containing the JSON data fetched from each URL,
|
|
100
|
+
or None on error.
|
|
101
|
+
:rtype: List[Optional[Dict]]
|
|
102
|
+
"""
|
|
103
|
+
results = []
|
|
104
|
+
async with aiohttp.ClientSession() as session:
|
|
105
|
+
for i in range(0, len(urls), self.max_concurrency):
|
|
106
|
+
batch = urls[i: i + self.max_concurrency]
|
|
107
|
+
tasks = [self.fetch_json(url, session) for url in batch]
|
|
108
|
+
batch_results = await asyncio.gather(*tasks)
|
|
109
|
+
results.extend(batch_results)
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
@check_token
|
|
113
|
+
async def get_policies(self) -> Optional[List]:
|
|
114
|
+
"""
|
|
115
|
+
Asynchronously retrieves all patch software titles' IDs using the Jamf API.
|
|
116
|
+
|
|
117
|
+
:return: List of software title IDs or None on error.
|
|
118
|
+
:rtype: Optional[List]
|
|
119
|
+
"""
|
|
120
|
+
async with aiohttp.ClientSession() as session:
|
|
121
|
+
url = f"{self.jamf_url}/api/v2/patch-software-title-configurations"
|
|
122
|
+
response = await self.fetch_json(url=url, session=session)
|
|
123
|
+
|
|
124
|
+
# Verify response is list type as expected
|
|
125
|
+
if not isinstance(response, list):
|
|
126
|
+
self.log.error(
|
|
127
|
+
f"Unexpected response format: expected a list, received {type(response)} instead."
|
|
128
|
+
)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# Check if all elements in the list are dictionaries
|
|
132
|
+
if not all(isinstance(item, dict) for item in response):
|
|
133
|
+
self.log.error(
|
|
134
|
+
"Unexpected response format: all items should be dictionaries."
|
|
135
|
+
)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
self.log.info("Patch policies obtained as expected.")
|
|
139
|
+
return [title.get("id") for title in response]
|
|
140
|
+
|
|
141
|
+
@check_token
|
|
142
|
+
async def get_summaries(self, policy_ids: List) -> Optional[List]:
|
|
143
|
+
"""
|
|
144
|
+
Retrieves active patch summaries for given policy IDs using the Jamf API.
|
|
145
|
+
|
|
146
|
+
:param policy_ids: List of policy IDs to retrieve summaries for.
|
|
147
|
+
:type policy_ids: List
|
|
148
|
+
:return: List of dictionaries containing patch summaries or None on error.
|
|
149
|
+
:rtype: Optional[List]
|
|
150
|
+
"""
|
|
151
|
+
urls = [
|
|
152
|
+
f"{self.jamf_url}/api/v2/patch-software-title-configurations/{policy}/patch-summary"
|
|
153
|
+
for policy in policy_ids
|
|
154
|
+
]
|
|
155
|
+
summaries = await self.fetch_batch(urls)
|
|
156
|
+
|
|
157
|
+
policy_summaries = [
|
|
158
|
+
{
|
|
159
|
+
"software_title": summary.get("title"),
|
|
160
|
+
"patch_released": self.convert_timezone(summary.get("releaseDate")),
|
|
161
|
+
"hosts_patched": summary.get("upToDate"),
|
|
162
|
+
"missing_patch": summary.get("outOfDate"),
|
|
163
|
+
"completion_percent": (
|
|
164
|
+
round(
|
|
165
|
+
(
|
|
166
|
+
summary.get("upToDate")
|
|
167
|
+
/ (summary.get("upToDate") + summary.get("outOfDate"))
|
|
168
|
+
)
|
|
169
|
+
* 100,
|
|
170
|
+
2,
|
|
171
|
+
)
|
|
172
|
+
if summary.get("upToDate") + summary.get("outOfDate") > 0
|
|
173
|
+
else 0
|
|
174
|
+
),
|
|
175
|
+
"total_hosts": summary.get("upToDate") + summary.get("outOfDate"),
|
|
176
|
+
}
|
|
177
|
+
for summary in summaries
|
|
178
|
+
if summary
|
|
179
|
+
]
|
|
180
|
+
self.log.info(
|
|
181
|
+
f"Successfully obtained policy summaries for {len(policy_summaries)} policies."
|
|
182
|
+
)
|
|
183
|
+
return policy_summaries
|
|
184
|
+
|
|
185
|
+
@check_token
|
|
186
|
+
async def get_device_ids(self) -> Optional[List[int]]:
|
|
187
|
+
"""
|
|
188
|
+
Asynchronously fetches the list of mobile device IDs from the Jamf Pro API.
|
|
189
|
+
|
|
190
|
+
:return: A list of mobile device IDs or None on error.
|
|
191
|
+
:rtype: Optional[List[int]]
|
|
192
|
+
"""
|
|
193
|
+
url = f"{self.jamf_url}/api/v2/mobile-devices"
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
async with aiohttp.ClientSession() as session:
|
|
197
|
+
response = await self.fetch_json(url=url, session=session)
|
|
198
|
+
except aiohttp.ClientError as e:
|
|
199
|
+
self.log.error(f"Error fetching device IDs: {e}")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
if not response:
|
|
203
|
+
self.log.error(f"API call to {url} was unsuccessful.")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
devices = response.get("results")
|
|
207
|
+
|
|
208
|
+
if not devices:
|
|
209
|
+
self.log.error("Received empty data set when trying to obtain device IDs.")
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
self.log.info(f"Received {len(devices)} device IDs successfully.")
|
|
213
|
+
return [device.get("id") for device in devices if device]
|
|
214
|
+
|
|
215
|
+
@check_token
|
|
216
|
+
async def get_device_os_versions(
|
|
217
|
+
self,
|
|
218
|
+
device_ids: List[int],
|
|
219
|
+
) -> Optional[List[Dict[AnyStr, AnyStr]]]:
|
|
220
|
+
"""
|
|
221
|
+
Asynchronously fetches the OS version and serial number for each device ID from the Jamf Pro API.
|
|
222
|
+
|
|
223
|
+
:param device_ids: A list of mobile device IDs.
|
|
224
|
+
:type device_ids: List[int]
|
|
225
|
+
:return: A list of dictionaries containing the serial number and OS version, or None on error.
|
|
226
|
+
:rtype: Optional[List[Dict[AnyStr, AnyStr]]]
|
|
227
|
+
"""
|
|
228
|
+
if not device_ids:
|
|
229
|
+
self.log.error("No device IDs provided!")
|
|
230
|
+
return None
|
|
231
|
+
urls = [
|
|
232
|
+
f"{self.jamf_url}/api/v2/mobile-devices/{device}/detail"
|
|
233
|
+
for device in device_ids
|
|
234
|
+
]
|
|
235
|
+
subsets = await self.fetch_batch(urls)
|
|
236
|
+
|
|
237
|
+
if not subsets:
|
|
238
|
+
self.log.error("Received empty response obtaining device OS information.")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
devices = [
|
|
242
|
+
{
|
|
243
|
+
"SN": subset.get("serialNumber"),
|
|
244
|
+
"OS": subset.get("osVersion"),
|
|
245
|
+
}
|
|
246
|
+
for subset in subsets
|
|
247
|
+
if subset
|
|
248
|
+
]
|
|
249
|
+
self.log.info(f"Successfully obtained OS versions for {len(devices)} devices.")
|
|
250
|
+
return devices
|
|
251
|
+
|
|
252
|
+
def get_sofa_feed(self) -> Optional[List[Dict[AnyStr, AnyStr]]]:
|
|
253
|
+
"""
|
|
254
|
+
Fetches iOS Data feeds from SOFA and extracts latest OS version information
|
|
255
|
+
|
|
256
|
+
:return: A list of dictionaries containing Base OS Version, latest iOS Version and release date,
|
|
257
|
+
or None on error.
|
|
258
|
+
:rtype: Optional[List[Dict[AnyStr, AnyStr]]]
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
# Utilize curl to avoid SSL Verification errors for end-users on managed devices
|
|
262
|
+
command = "curl -s 'https://sofa.macadmins.io/v1/ios_data_feed.json'"
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
command, shell=True, capture_output=True, text=True, check=True
|
|
267
|
+
)
|
|
268
|
+
except (subprocess.CalledProcessError, aiohttp.ClientResponseError) as e:
|
|
269
|
+
self.log.error(f"Encountered error executing subprocess command: {e}")
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
data = json.loads(result.stdout)
|
|
274
|
+
except json.JSONDecodeError as e:
|
|
275
|
+
self.log.error(f"Error decoding JSON data: {e}")
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
os_versions = data.get("OSVersions", [])
|
|
279
|
+
latest_versions = []
|
|
280
|
+
for version in os_versions:
|
|
281
|
+
version_info = version.get("Latest", {})
|
|
282
|
+
latest_versions.append(
|
|
283
|
+
{
|
|
284
|
+
"OSVersion": version.get("OSVersion"),
|
|
285
|
+
"ProductVersion": version_info.get("ProductVersion"),
|
|
286
|
+
"ReleaseDate": self.convert_timezone(
|
|
287
|
+
version_info.get("ReleaseDate")
|
|
288
|
+
),
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
return latest_versions
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import keyring
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from src.Patcher.model.models import AccessToken, JamfClient
|
|
4
|
+
from src.Patcher import logger
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from typing import Optional, AnyStr
|
|
7
|
+
|
|
8
|
+
logthis = logger.setup_child_logger("ConfigManager", __name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigManager:
|
|
12
|
+
"""Manages configuration settings, mainly loading and saving credentials in keychain"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, service_name: AnyStr = "patcher"):
|
|
15
|
+
"""
|
|
16
|
+
Initializes the ConfigManager with a specific service name.
|
|
17
|
+
|
|
18
|
+
:param service_name: The name of the service for storing credentials in the keyring.
|
|
19
|
+
Defaults to 'patcher'.
|
|
20
|
+
:type service_name: AnyStr
|
|
21
|
+
"""
|
|
22
|
+
logthis.debug(f"Initializing ConfigManager with service name: {service_name}")
|
|
23
|
+
self.service_name = service_name
|
|
24
|
+
|
|
25
|
+
def get_credential(self, key: AnyStr) -> AnyStr:
|
|
26
|
+
"""
|
|
27
|
+
Retrieves a specified credential from the keyring.
|
|
28
|
+
|
|
29
|
+
:param key: The key of the credential to retrieve.
|
|
30
|
+
:type key: AnyStr
|
|
31
|
+
:return: The retrieved credential value.
|
|
32
|
+
:rtype: AnyStr
|
|
33
|
+
"""
|
|
34
|
+
logthis.debug(f"Retrieving credential for key: {key}")
|
|
35
|
+
credential = keyring.get_password(self.service_name, key)
|
|
36
|
+
if credential:
|
|
37
|
+
logthis.info(f"Credential for key '{key}' retrieved successfully")
|
|
38
|
+
else:
|
|
39
|
+
logthis.warning(f"No credential found for key: {key}")
|
|
40
|
+
return credential
|
|
41
|
+
|
|
42
|
+
def set_credential(self, key: AnyStr, value: AnyStr):
|
|
43
|
+
"""
|
|
44
|
+
Sets a credential in the keyring.
|
|
45
|
+
|
|
46
|
+
:param key: The key of the credential to set.
|
|
47
|
+
:type key: AnyStr
|
|
48
|
+
:param value: The value of the credential to set.
|
|
49
|
+
:type value: AnyStr
|
|
50
|
+
"""
|
|
51
|
+
logthis.debug(f"Setting credential for key: {key}")
|
|
52
|
+
keyring.set_password(self.service_name, key, value)
|
|
53
|
+
logthis.info(f"Credential for key '{key}' set successfully")
|
|
54
|
+
|
|
55
|
+
def load_token(self) -> AccessToken:
|
|
56
|
+
"""
|
|
57
|
+
Loads the access token from the keyring.
|
|
58
|
+
|
|
59
|
+
:return: The access token with its expiration date.
|
|
60
|
+
:rtype: AccessToken
|
|
61
|
+
"""
|
|
62
|
+
logthis.debug("Loading token from keyring")
|
|
63
|
+
token = self.get_credential("TOKEN") or ""
|
|
64
|
+
expires = (
|
|
65
|
+
self.get_credential("TOKEN_EXPIRATION")
|
|
66
|
+
or datetime(1970, 1, 1, tzinfo=timezone.utc).isoformat()
|
|
67
|
+
)
|
|
68
|
+
logthis.info("Token and expiration loaded from keyring")
|
|
69
|
+
return AccessToken(token=token, expires=expires)
|
|
70
|
+
|
|
71
|
+
def attach_client(self) -> Optional[JamfClient]:
|
|
72
|
+
"""
|
|
73
|
+
Attaches a Jamf client using the stored credentials.
|
|
74
|
+
|
|
75
|
+
:return: The Jamf client if validation is successful, None otherwise.
|
|
76
|
+
:rtype: Optional[JamfClient]
|
|
77
|
+
"""
|
|
78
|
+
logthis.debug("Attaching Jamf client with stored credentials")
|
|
79
|
+
try:
|
|
80
|
+
client = JamfClient(
|
|
81
|
+
client_id=self.get_credential("CLIENT_ID"),
|
|
82
|
+
client_secret=self.get_credential("CLIENT_SECRET"),
|
|
83
|
+
server=self.get_credential("URL"),
|
|
84
|
+
token=self.load_token(),
|
|
85
|
+
)
|
|
86
|
+
logthis.info("Jamf client attached successfully")
|
|
87
|
+
return client
|
|
88
|
+
except ValidationError as e:
|
|
89
|
+
logthis.error(f"Jamf Client failed validation: {e}")
|
|
90
|
+
return None
|