patcherctl 1.3.5b1__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.5b1"
patcher/__init__.py ADDED
File without changes
patcher/cli.py ADDED
@@ -0,0 +1,154 @@
1
+ import asyncio
2
+ from typing import AnyStr, Optional
3
+
4
+ import asyncclick as click
5
+
6
+ from .__about__ import __version__
7
+ from .client.api_client import ApiClient
8
+ from .client.config_manager import ConfigManager
9
+ from .client.report_manager import ReportManager
10
+ from .client.setup import Setup
11
+ from .client.token_manager import TokenManager
12
+ from .client.ui_manager import UIConfigManager
13
+ from .models.reports.excel_report import ExcelReport
14
+ from .models.reports.pdf_report import PDFReport
15
+ from .utils.animation import Animation
16
+ from .utils.exceptions import PatcherError
17
+ from .utils.logger import LogMe
18
+
19
+ DATE_FORMATS = {
20
+ "Month-Year": "%B %Y", # April 2024
21
+ "Month-Day-Year": "%B %d %Y", # April 21 2024
22
+ "Year-Month-Day": "%Y %B %d", # 2024 April 21
23
+ "Day-Month-Year": "%d %B %Y", # 16 April 2024
24
+ "Full": "%A %B %d %Y", # Thursday September 26 2013
25
+ }
26
+
27
+
28
+ @click.command()
29
+ @click.version_option(version=__version__)
30
+ @click.option(
31
+ "--path",
32
+ "-p",
33
+ type=click.Path(),
34
+ required=False, # Defaulting to false in favor of `--reset`
35
+ help="Path to save the report(s)",
36
+ )
37
+ @click.option(
38
+ "--pdf",
39
+ "-f",
40
+ is_flag=True,
41
+ help="Generate a PDF report along with Excel spreadsheet",
42
+ )
43
+ @click.option(
44
+ "--sort",
45
+ "-s",
46
+ type=click.STRING,
47
+ required=False,
48
+ help="Sort patch reports by a specified column.",
49
+ )
50
+ @click.option(
51
+ "--omit",
52
+ "-o",
53
+ is_flag=True,
54
+ help="Omit software titles with patches released in last 48 hours",
55
+ )
56
+ @click.option(
57
+ "--date-format",
58
+ "-d",
59
+ type=click.Choice(list(DATE_FORMATS.keys()), case_sensitive=False),
60
+ default="Month-Day-Year",
61
+ help="Specify the date format for the PDF header from predefined choices.",
62
+ )
63
+ @click.option(
64
+ "--ios",
65
+ "-m",
66
+ is_flag=True,
67
+ help="Include the amount of enrolled mobile devices on the latest version of their respective OS.",
68
+ )
69
+ @click.option(
70
+ "--concurrency",
71
+ type=click.INT,
72
+ default=5,
73
+ help="Set the maximum concurrency level for API calls.",
74
+ )
75
+ @click.option(
76
+ "--debug",
77
+ "-x",
78
+ is_flag=True,
79
+ default=False,
80
+ help="Enable debug logging to see detailed debug messages.",
81
+ )
82
+ @click.option(
83
+ "--reset",
84
+ "-r",
85
+ is_flag=True,
86
+ default=False,
87
+ help="Resets the setup process and triggers the setup assistant again.",
88
+ )
89
+ @click.option(
90
+ "--custom-ca-file",
91
+ type=click.Path(),
92
+ required=False,
93
+ help="Path to a custom CA file for SSL verification.",
94
+ )
95
+ @click.pass_context
96
+ async def main(
97
+ ctx: click.Context,
98
+ path: AnyStr,
99
+ pdf: bool,
100
+ sort: Optional[AnyStr],
101
+ omit: bool,
102
+ date_format: AnyStr,
103
+ ios: bool,
104
+ concurrency: int,
105
+ debug: bool,
106
+ reset: bool,
107
+ custom_ca_file: Optional[str],
108
+ ) -> None:
109
+ if not ctx.params["reset"] and not ctx.params["path"]:
110
+ raise click.UsageError("The --path option is required unless --reset is specified.")
111
+
112
+ config = ConfigManager()
113
+ ui_config = UIConfigManager(custom_ca_file=custom_ca_file)
114
+ setup = Setup(config=config, ui_config=ui_config, custom_ca_file=custom_ca_file)
115
+
116
+ log = LogMe(__name__, debug=debug)
117
+ animation = Animation(enable_animation=not debug)
118
+
119
+ async with animation.error_handling(log):
120
+ if not setup.completed:
121
+ await setup.prompt_method(animator=animation)
122
+ click.echo(click.style(text="Setup has completed successfully!", fg="green", bold=True))
123
+ click.echo("Patcher is now ready for use.")
124
+ click.echo("You can use the --help flag to view available options.")
125
+ click.echo("For more information, visit the project docs: https://patcher.liquidzoo.io")
126
+ return
127
+ elif reset:
128
+ await animation.update_msg("Resetting elements...")
129
+ await setup.reset()
130
+ click.echo(click.style(text="Reset has completed as expected!", fg="green", bold=True))
131
+ return
132
+
133
+ jamf_client = config.attach_client(custom_ca_file=custom_ca_file)
134
+ if jamf_client is None:
135
+ raise PatcherError(message="Invalid JamfClient configuration detected!")
136
+
137
+ token_manager = TokenManager(config)
138
+
139
+ api_client = ApiClient(config)
140
+ api_client.jamf_client = jamf_client
141
+ excel_report = ExcelReport()
142
+ pdf_report = PDFReport(ui_config)
143
+ api_client.jamf_client.set_max_concurrency(concurrency=concurrency)
144
+
145
+ patcher = ReportManager(
146
+ config, token_manager, api_client, excel_report, pdf_report, ui_config, debug
147
+ )
148
+
149
+ actual_format = DATE_FORMATS[date_format]
150
+ await patcher.process_reports(path, pdf, sort, omit, ios, actual_format)
151
+
152
+
153
+ if __name__ == "__main__":
154
+ asyncio.run(main())
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,301 @@
1
+ import asyncio
2
+ import json
3
+ import ssl
4
+ import subprocess
5
+ from datetime import datetime
6
+ from typing import AnyStr, Dict, List, Optional
7
+
8
+ import aiohttp
9
+
10
+ from patcher.utils.wrappers import check_token
11
+
12
+ from ..models.patch import PatchTitle
13
+ from ..utils import logger
14
+ from .config_manager import ConfigManager
15
+ from .token_manager import TokenManager
16
+
17
+
18
+ class ApiClient:
19
+ """
20
+ Provides methods for interacting with the Jamf API, specifically fetching patch data, device information, and OS versions.
21
+
22
+ The ``ApiClient`` manages authentication and session handling, ensuring efficient and secure communication with the Jamf API.
23
+ """
24
+
25
+ def __init__(self, config: ConfigManager):
26
+ """
27
+ Initializes the ApiClient with the provided :class:`~patcher.client.config_manager.ConfigManager`.
28
+
29
+ This sets up the API client with necessary credentials and session parameters for interacting with the Jamf API.
30
+
31
+ .. seealso::
32
+ :mod:`~patcher.models.jamf_client`
33
+
34
+ :param config: Instance of ConfigManager for loading and storing credentials.
35
+ :type config: ConfigManager
36
+ :raises ValueError: If the JamfClient configuration is invalid.
37
+ """
38
+ self.log = logger.LogMe(self.__class__.__name__)
39
+ self.log.debug("Initializing ApiClient")
40
+ self.config = config
41
+ self.jamf_client = config.attach_client()
42
+ if self.jamf_client:
43
+ self.token = self.jamf_client.token
44
+ self.log.info("JamfClient and token successfully attached")
45
+ else:
46
+ self.log.error("Invalid JamfClient configuration detected!")
47
+ raise ValueError("Invalid JamfClient configuration detected!")
48
+ self.jamf_url = self.jamf_client.base_url
49
+ self.headers = {
50
+ "Accept": "application/json",
51
+ "Authorization": f"Bearer {self.token}",
52
+ }
53
+ self.token_manager = TokenManager(config)
54
+ self.max_concurrency = self.jamf_client.max_concurrency
55
+ self.ssl_context = ssl.create_default_context(cafile=self.jamf_client.cafile)
56
+
57
+ def convert_timezone(self, utc_time_str: AnyStr) -> Optional[AnyStr]:
58
+ """
59
+ Converts a UTC time string to a formatted string without timezone information.
60
+
61
+ :param utc_time_str: UTC time string in ISO 8601 format (e.g., "2023-08-09T12:34:56+0000").
62
+ :type utc_time_str: AnyStr
63
+ :return: Formatted date string (e.g., "Aug 09 2023") or None if the input format is invalid.
64
+ :rtype: Optional[AnyStr]
65
+ :example:
66
+
67
+ .. code-block:: python
68
+
69
+ formatted_date = api_client.convert_timezone("2023-08-09T12:34:56+0000")
70
+ print(formatted_date) # Outputs: "Aug 09 2023"
71
+
72
+ .. note::
73
+
74
+ This function is primarily used 'privately' by methods and classes and is not designed to be called explicitly.
75
+
76
+ """
77
+ try:
78
+ utc_time = datetime.strptime(utc_time_str, "%Y-%m-%dT%H:%M:%S%z")
79
+ time_str = utc_time.strftime("%b %d %Y")
80
+ return time_str
81
+ except ValueError as e:
82
+ self.log.error(f"Invalid time format provided. Details: {e}")
83
+ return None
84
+
85
+ async def fetch_json(self, url: AnyStr, session: aiohttp.ClientSession) -> Optional[Dict]:
86
+ """
87
+ Asynchronously fetches JSON data from a specified URL using a session.
88
+
89
+ :param url: URL to fetch the JSON data from.
90
+ :type url: AnyStr
91
+ :param session: An aiohttp.ClientSession instance used to make the request.
92
+ :type session: aiohttp.ClientSession
93
+ :return: JSON data as a dictionary or None if an error occurs.
94
+ :rtype: Optional[Dict]
95
+ :example:
96
+
97
+ .. code-block:: python
98
+
99
+ async with aiohttp.ClientSession() as session:
100
+ json_data = await api_client.fetch_json("https://api.example.com/data", session)
101
+ if json_data:
102
+ print(json_data)
103
+ """
104
+ self.log.debug(f"Fetching JSON data from URL: {url}")
105
+ try:
106
+ async with session.get(url, headers=self.headers, ssl=self.ssl_context) as response:
107
+ response.raise_for_status()
108
+ json_data = await response.json()
109
+ self.log.info(f"Successfully fetched JSON data from {url}")
110
+ return json_data
111
+ except aiohttp.ClientResponseError as e:
112
+ self.log.error(f"Received a client error while fetching JSON from {url}: {e}")
113
+ except Exception as e:
114
+ self.log.error(f"Error fetching JSON: {e}")
115
+ return None
116
+
117
+ async def fetch_batch(self, urls: List[AnyStr]) -> List[Optional[Dict]]:
118
+ """
119
+ Fetches JSON data in batches to respect the concurrency limit. Data is fetched
120
+ from each URL in the provided list, ensuring that no more than ``max_concurrency``
121
+ requests are sent concurrently.
122
+
123
+ :param urls: List of URLs to fetch data from.
124
+ :type urls: List[AnyStr]
125
+ :return: A list of JSON dictionaries or None for URLs that fail to retrieve data.
126
+ :rtype: List[Optional[Dict]]
127
+ """
128
+ results = []
129
+ async with aiohttp.ClientSession() as session:
130
+ for i in range(0, len(urls), self.max_concurrency):
131
+ batch = urls[i : i + self.max_concurrency]
132
+ tasks = [self.fetch_json(url, session) for url in batch]
133
+ batch_results = await asyncio.gather(*tasks)
134
+ results.extend(batch_results)
135
+ return results
136
+
137
+ @check_token
138
+ async def get_policies(self) -> Optional[List]:
139
+ """
140
+ Retrieves a list of patch software title IDs from the Jamf API. This function
141
+ requires a valid authentication token, which is managed automatically.
142
+
143
+ :return: A list of software title IDs or None if an error occurs.
144
+ :rtype: Optional[List]
145
+ """
146
+ async with aiohttp.ClientSession() as session:
147
+ url = f"{self.jamf_url}/api/v2/patch-software-title-configurations"
148
+ response = await self.fetch_json(url=url, session=session)
149
+
150
+ # Verify response is list type as expected
151
+ if not isinstance(response, list):
152
+ self.log.error(
153
+ f"Unexpected response format: expected a list, received {type(response)} instead."
154
+ )
155
+ return None
156
+
157
+ # Check if all elements in the list are dictionaries
158
+ if not all(isinstance(item, dict) for item in response):
159
+ self.log.error("Unexpected response format: all items should be dictionaries.")
160
+ return None
161
+
162
+ self.log.info("Patch policies obtained as expected.")
163
+ return [title.get("id") for title in response]
164
+
165
+ @check_token
166
+ async def get_summaries(self, policy_ids: List) -> Optional[List[PatchTitle]]:
167
+ """
168
+ Retrieves patch summaries for the specified policy IDs from the Jamf API. This function
169
+ fetches data asynchronously and compiles the results into a list of ``PatchTitle`` objects.
170
+
171
+ :param policy_ids: List of policy IDs to retrieve summaries for.
172
+ :type policy_ids: List
173
+ :return: List of ``PatchTitle`` objects containing patch summaries or None if an error occurs.
174
+ :rtype: Optional[List[PatchTitle]]
175
+ """
176
+ urls = [
177
+ f"{self.jamf_url}/api/v2/patch-software-title-configurations/{policy}/patch-summary"
178
+ for policy in policy_ids
179
+ ]
180
+ summaries = await self.fetch_batch(urls)
181
+
182
+ policy_summaries = [
183
+ PatchTitle(
184
+ title=summary.get("title"),
185
+ released=self.convert_timezone(summary.get("releaseDate")),
186
+ hosts_patched=summary.get("upToDate"),
187
+ missing_patch=summary.get("outOfDate"),
188
+ )
189
+ for summary in summaries
190
+ if summary
191
+ ]
192
+ self.log.info(
193
+ f"Successfully obtained policy summaries for {len(policy_summaries)} policies."
194
+ )
195
+ return policy_summaries
196
+
197
+ @check_token
198
+ async def get_device_ids(self) -> Optional[List[int]]:
199
+ """
200
+ Asynchronously fetches the list of mobile device IDs from the Jamf Pro API.
201
+ This method is only called if the :ref:`iOS <ios>` option is passed to the CLI.
202
+
203
+ :return: A list of mobile device IDs or None on error.
204
+ :rtype: Optional[List[int]]
205
+ """
206
+ url = f"{self.jamf_url}/api/v2/mobile-devices"
207
+
208
+ try:
209
+ async with aiohttp.ClientSession() as session:
210
+ response = await self.fetch_json(url=url, session=session)
211
+ except aiohttp.ClientError as e:
212
+ self.log.error(f"Error fetching device IDs: {e}")
213
+ return None
214
+
215
+ if not response:
216
+ self.log.error(f"API call to {url} was unsuccessful.")
217
+ return None
218
+
219
+ devices = response.get("results")
220
+
221
+ if not devices:
222
+ self.log.error("Received empty data set when trying to obtain device IDs.")
223
+ return None
224
+
225
+ self.log.info(f"Received {len(devices)} device IDs successfully.")
226
+ return [device.get("id") for device in devices if device]
227
+
228
+ @check_token
229
+ async def get_device_os_versions(
230
+ self,
231
+ device_ids: List[int],
232
+ ) -> Optional[List[Dict[AnyStr, AnyStr]]]:
233
+ """
234
+ Asynchronously fetches the OS version and serial number for each device ID provided.
235
+ This method is only called if the :ref:`iOS <ios>` option is passed to the CLI.
236
+
237
+ :param device_ids: A list of mobile device IDs to retrieve information for.
238
+ :type device_ids: List[int]
239
+ :return: A list of dictionaries containing the serial numbers and OS versions, or None on error.
240
+ :rtype: Optional[List[Dict[AnyStr, AnyStr]]]
241
+ """
242
+ if not device_ids:
243
+ self.log.error("No device IDs provided!")
244
+ return None
245
+ urls = [f"{self.jamf_url}/api/v2/mobile-devices/{device}/detail" for device in device_ids]
246
+ subsets = await self.fetch_batch(urls)
247
+
248
+ if not subsets:
249
+ self.log.error("Received empty response obtaining device OS information.")
250
+ return None
251
+
252
+ devices = [
253
+ {
254
+ "SN": subset.get("serialNumber"),
255
+ "OS": subset.get("osVersion"),
256
+ }
257
+ for subset in subsets
258
+ if subset
259
+ ]
260
+ self.log.info(f"Successfully obtained OS versions for {len(devices)} devices.")
261
+ return devices
262
+
263
+ def get_sofa_feed(self) -> Optional[List[Dict[AnyStr, AnyStr]]]:
264
+ """
265
+ Fetches iOS Data feeds from SOFA and extracts latest OS version information.
266
+ To limit the amount of possible SSL verification checks, this method utilizes a subprocess call
267
+ instead.
268
+ This method is only called if the :ref:`iOS <ios>` option is passed to the CLI.
269
+
270
+ :return: A list of dictionaries containing base OS versions, latest iOS versions and release dates,
271
+ or None on error.
272
+ :rtype: Optional[List[Dict[AnyStr, AnyStr]]]
273
+ """
274
+
275
+ # Utilize curl to avoid SSL Verification errors for end-users on managed devices
276
+ command = "curl -s 'https://sofafeed.macadmins.io/v1/ios_data_feed.json'"
277
+
278
+ try:
279
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, check=True)
280
+ except (subprocess.CalledProcessError, aiohttp.ClientResponseError) as e:
281
+ self.log.error(f"Encountered error executing subprocess command: {e}")
282
+ return None
283
+
284
+ try:
285
+ data = json.loads(result.stdout)
286
+ except json.JSONDecodeError as e:
287
+ self.log.error(f"Error decoding JSON data: {e}")
288
+ return None
289
+
290
+ os_versions = data.get("OSVersions", [])
291
+ latest_versions = []
292
+ for version in os_versions:
293
+ version_info = version.get("Latest", {})
294
+ latest_versions.append(
295
+ {
296
+ "OSVersion": version.get("OSVersion"),
297
+ "ProductVersion": version_info.get("ProductVersion"),
298
+ "ReleaseDate": self.convert_timezone(version_info.get("ReleaseDate")),
299
+ }
300
+ )
301
+ return latest_versions
@@ -0,0 +1,144 @@
1
+ from datetime import datetime, timezone
2
+ from typing import AnyStr, Optional
3
+
4
+ import keyring
5
+ from pydantic import ValidationError
6
+
7
+ from ..models.jamf_client import JamfClient
8
+ from ..models.token import AccessToken
9
+ from ..utils import logger
10
+
11
+
12
+ class ConfigManager:
13
+ """
14
+ Manages configuration settings, primarily focused on handling credentials stored in the macOS keychain.
15
+
16
+ This class provides methods to securely store, retrieve, and manage sensitive information such as
17
+ API tokens and client credentials. It integrates with the ``keyring`` library to interface with the macOS keychain.
18
+ """
19
+
20
+ def __init__(self, service_name: AnyStr = "Patcher"):
21
+ """
22
+ Initializes the ConfigManager with a specific service name.
23
+
24
+ This service name is used as a namespace for storing and retrieving credentials in the keyring,
25
+ allowing you to organize credentials by the service they pertain to.
26
+
27
+ :param service_name: The name of the service for storing credentials in the keyring.
28
+ Defaults to 'Patcher'.
29
+ :type service_name: AnyStr
30
+ :example:
31
+
32
+ .. code-block:: python
33
+
34
+ config = ConfigManager("MyService")
35
+ """
36
+ self.log = logger.LogMe(self.__class__.__name__)
37
+ self.service_name = service_name
38
+ self.log.debug(f"Initializing ConfigManager with service name: {service_name}")
39
+
40
+ def get_credential(self, key: AnyStr) -> AnyStr:
41
+ """
42
+ Retrieves a specified credential from the keyring associated with the given key.
43
+
44
+ This method is useful for accessing stored credentials without hardcoding them in scripts.
45
+ It ensures that sensitive data like passwords or API tokens are securely stored and retrieved.
46
+
47
+ :param key: The key of the credential to retrieve, typically a descriptive name like 'API_KEY'.
48
+ :type key: AnyStr
49
+ :return: The retrieved credential value. If the key does not exist, returns ``None``.
50
+ :rtype: AnyStr
51
+ :example:
52
+
53
+ .. code-block:: python
54
+
55
+ token = config.get_credential("API_TOKEN")
56
+ """
57
+ self.log.debug(f"Retrieving credential for key: {key}")
58
+ credential = keyring.get_password(self.service_name, key)
59
+ if credential:
60
+ self.log.info(f"Credential for key '{key}' retrieved successfully")
61
+ else:
62
+ self.log.warning(f"No credential found for key: {key}")
63
+ return credential
64
+
65
+ def set_credential(self, key: AnyStr, value: AnyStr):
66
+ """
67
+ Stores a credential in the keyring under the specified key.
68
+
69
+ Method is used to securely store sensitive data such as Jamf URL, API Tokens, usernames
70
+ and passwords.
71
+
72
+ :param key: The key under which the credential will be stored. This acts as an identifier for the credential.
73
+ :type key: AnyStr
74
+ :param value: The value of the credential to store, such as a password or API token.
75
+ :type value: AnyStr
76
+ """
77
+ self.log.debug(f"Setting credential for key: {key}")
78
+ keyring.set_password(self.service_name, key, value)
79
+ self.log.info(f"Credential for key '{key}' set successfully")
80
+
81
+ def load_token(self) -> AccessToken:
82
+ """
83
+ Loads the access token and its expiration from the keyring.
84
+
85
+ :return: An :class:`~patcher.models.token.AccessToken` object containing the token and its
86
+ expiration date.
87
+ :rtype: AccessToken
88
+ """
89
+ self.log.debug("Loading token from keyring")
90
+ token = self.get_credential("TOKEN") or ""
91
+ expires = (
92
+ self.get_credential("TOKEN_EXPIRATION")
93
+ or datetime(1970, 1, 1, tzinfo=timezone.utc).isoformat()
94
+ )
95
+ self.log.info("Token and expiration loaded from keyring")
96
+ return AccessToken(token=token, expires=expires)
97
+
98
+ def attach_client(self, custom_ca_file: Optional[str] = None) -> Optional[JamfClient]:
99
+ """
100
+ Creates and returns a :mod:`patcher.models.jamf_client` object using the stored credentials.
101
+ Allows for an optional custom CA file to be passed, which can be useful for environments
102
+ with custom certificate authorities.
103
+
104
+ :param custom_ca_file: Optional path to a custom CA file for SSL verification.
105
+ :type custom_ca_file: Optional[str]
106
+ :return: The ``JamfClient`` object if validation is successful, None otherwise.
107
+ :rtype: Optional[JamfClient]
108
+ """
109
+ self.log.debug("Attaching Jamf client with stored credentials")
110
+ try:
111
+ client = JamfClient(
112
+ client_id=self.get_credential("CLIENT_ID"),
113
+ client_secret=self.get_credential("CLIENT_SECRET"),
114
+ server=self.get_credential("URL"),
115
+ token=self.load_token(),
116
+ custom_ca_file=custom_ca_file,
117
+ )
118
+ self.log.info("Jamf client attached successfully")
119
+ return client
120
+ except ValidationError as e:
121
+ self.log.error(f"Jamf Client failed validation: {e}")
122
+ return None
123
+
124
+ def create_client(self, client: JamfClient):
125
+ """
126
+ Stores a `JamfClient` object's credentials in the keyring.
127
+
128
+ This method is typically used during the setup process to save the credentials and token of a `JamfClient`
129
+ object into the keyring for secure storage and later use.
130
+
131
+ :param client: The `JamfClient` object whose credentials will be stored.
132
+ :type client: JamfClient
133
+ """
134
+ self.log.debug(f"Setting Jamf client: {client.client_id}")
135
+ credentials = {
136
+ "CLIENT_ID": client.client_id,
137
+ "CLIENT_SECRET": client.client_secret,
138
+ "URL": client.server,
139
+ "TOKEN": client.token.token,
140
+ "TOKEN_EXPIRATION": client.token.expires.isoformat(),
141
+ }
142
+ for key, value in credentials.items():
143
+ self.set_credential(key, value)
144
+ self.log.info("Jamf client credentials and token saved successfully")