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 ADDED
@@ -0,0 +1,2 @@
1
+ __title__ = "patcher"
2
+ __version__ = "1.3.1"
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