patcherctl 1.4.2__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 +141 -0
- patcher/client/__init__.py +335 -0
- patcher/client/analyze.py +133 -0
- patcher/client/api_client.py +209 -0
- patcher/client/config_manager.py +130 -0
- patcher/client/report_manager.py +349 -0
- patcher/client/setup.py +315 -0
- patcher/client/token_manager.py +166 -0
- patcher/client/ui_manager.py +358 -0
- patcher/models/__init__.py +7 -0
- patcher/models/jamf_client.py +165 -0
- patcher/models/patch.py +51 -0
- patcher/models/reports/__init__.py +0 -0
- patcher/models/reports/excel_report.py +57 -0
- patcher/models/reports/pdf_report.py +259 -0
- patcher/models/token.py +61 -0
- patcher/utils/__init__.py +0 -0
- patcher/utils/animation.py +131 -0
- patcher/utils/decorators.py +57 -0
- patcher/utils/exceptions.py +187 -0
- patcher/utils/logger.py +158 -0
- patcherctl-1.4.2.dist-info/LICENSE.txt +201 -0
- patcherctl-1.4.2.dist-info/METADATA +287 -0
- patcherctl-1.4.2.dist-info/RECORD +29 -0
- patcherctl-1.4.2.dist-info/WHEEL +5 -0
- patcherctl-1.4.2.dist-info/entry_points.txt +2 -0
- patcherctl-1.4.2.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,141 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import 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.logger import LogMe
|
|
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
|
+
@click.command()
|
|
28
|
+
@click.version_option(version=__version__)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--path",
|
|
31
|
+
"-p",
|
|
32
|
+
type=click.Path(),
|
|
33
|
+
required=False, # Defaulting to false in favor of `--reset`
|
|
34
|
+
help="Path to save the report(s)",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--pdf",
|
|
38
|
+
"-f",
|
|
39
|
+
is_flag=True,
|
|
40
|
+
help="Generate a PDF report along with Excel spreadsheet",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--sort",
|
|
44
|
+
"-s",
|
|
45
|
+
type=click.STRING,
|
|
46
|
+
required=False,
|
|
47
|
+
help="Sort patch reports by a specified column.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--omit",
|
|
51
|
+
"-o",
|
|
52
|
+
is_flag=True,
|
|
53
|
+
help="Omit software titles with patches released in last 48 hours",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--date-format",
|
|
57
|
+
"-d",
|
|
58
|
+
type=click.Choice(list(DATE_FORMATS.keys()), case_sensitive=False),
|
|
59
|
+
default="Month-Day-Year",
|
|
60
|
+
help="Specify the date format for the PDF header from predefined choices.",
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--ios",
|
|
64
|
+
"-m",
|
|
65
|
+
is_flag=True,
|
|
66
|
+
help="Include the amount of enrolled mobile devices on the latest version of their respective OS.",
|
|
67
|
+
)
|
|
68
|
+
@click.option(
|
|
69
|
+
"--concurrency",
|
|
70
|
+
type=click.INT,
|
|
71
|
+
default=5,
|
|
72
|
+
help="Set the maximum concurrency level for API calls.",
|
|
73
|
+
)
|
|
74
|
+
@click.option(
|
|
75
|
+
"--debug",
|
|
76
|
+
"-x",
|
|
77
|
+
is_flag=True,
|
|
78
|
+
default=False,
|
|
79
|
+
help="Enable debug logging to see detailed debug messages.",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--reset",
|
|
83
|
+
"-r",
|
|
84
|
+
is_flag=True,
|
|
85
|
+
default=False,
|
|
86
|
+
help="Resets the setup process and triggers the setup assistant again.",
|
|
87
|
+
)
|
|
88
|
+
@click.pass_context
|
|
89
|
+
async def main(
|
|
90
|
+
ctx: click.Context,
|
|
91
|
+
path: str,
|
|
92
|
+
pdf: bool,
|
|
93
|
+
sort: Optional[str],
|
|
94
|
+
omit: bool,
|
|
95
|
+
date_format: str,
|
|
96
|
+
ios: bool,
|
|
97
|
+
concurrency: int,
|
|
98
|
+
debug: bool,
|
|
99
|
+
reset: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
if not ctx.params["reset"] and not ctx.params["path"]:
|
|
102
|
+
raise click.UsageError("The --path option is required unless --reset is specified.")
|
|
103
|
+
|
|
104
|
+
log = LogMe(__name__, debug=debug)
|
|
105
|
+
animation = Animation(enable_animation=not debug)
|
|
106
|
+
|
|
107
|
+
config = ConfigManager()
|
|
108
|
+
ui_config = UIConfigManager()
|
|
109
|
+
|
|
110
|
+
setup = Setup(config=config, ui_config=ui_config)
|
|
111
|
+
|
|
112
|
+
async with animation.error_handling(log):
|
|
113
|
+
if not setup.completed:
|
|
114
|
+
await setup.prompt_method(animator=animation)
|
|
115
|
+
click.echo(click.style(text="Setup has completed successfully!", fg="green", bold=True))
|
|
116
|
+
click.echo("Patcher is now ready for use.")
|
|
117
|
+
click.echo("You can use the --help flag to view available options.")
|
|
118
|
+
click.echo("For more information, visit the project docs: https://patcher.liquidzoo.io")
|
|
119
|
+
return
|
|
120
|
+
elif reset:
|
|
121
|
+
await animation.update_msg("Resetting elements...")
|
|
122
|
+
await setup.reset()
|
|
123
|
+
click.echo(click.style(text="Reset has completed as expected!", fg="green", bold=True))
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
api_client = ApiClient(config, concurrency)
|
|
127
|
+
token_manager = TokenManager(config)
|
|
128
|
+
excel_report = ExcelReport()
|
|
129
|
+
pdf_report = PDFReport(ui_config)
|
|
130
|
+
api_client.set_concurrency(concurrency=concurrency)
|
|
131
|
+
|
|
132
|
+
patcher = ReportManager(
|
|
133
|
+
config, token_manager, api_client, excel_report, pdf_report, ui_config, debug
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
actual_format = DATE_FORMATS[date_format]
|
|
137
|
+
await patcher.process_reports(path, pdf, sort, omit, ios, actual_format)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
5
|
+
|
|
6
|
+
from ..models.jamf_client import ApiClientModel, ApiRoleModel
|
|
7
|
+
from ..utils import exceptions, logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseAPIClient:
|
|
11
|
+
"""
|
|
12
|
+
The BaseAPIClient class controls concurrency settings and secure connections for *all* API calls.
|
|
13
|
+
|
|
14
|
+
This class forms the backbone of Patcher's ability to interact with external APIs.
|
|
15
|
+
It manages the number of API requests that can be made simultaneously, ensuring the tool is both
|
|
16
|
+
efficient and does not overload any servers.
|
|
17
|
+
|
|
18
|
+
.. warning::
|
|
19
|
+
Changing the max_concurrency value could lead to your Jamf server being unable to perform other basic tasks.
|
|
20
|
+
It is **strongly recommended** to limit API call concurrency to no more than 5 connections.
|
|
21
|
+
See `Jamf Developer Guide <https://developer.jamf.com/developer-guide/docs/jamf-pro-api-scalability-best-practices>`_ for more information.
|
|
22
|
+
|
|
23
|
+
:param max_concurrency: The maximum number of API requests that can be sent at once. Defaults to 5.
|
|
24
|
+
:type max_concurrency: int
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, max_concurrency: int = 5):
|
|
28
|
+
self.max_concurrency = max_concurrency
|
|
29
|
+
self.semaphore = asyncio.Semaphore(max_concurrency)
|
|
30
|
+
self.default_headers = {"accept": "application/json", "Content-Type": "application/json"}
|
|
31
|
+
self.log = logger.LogMe(self.__class__.__name__)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def concurrency(self) -> int:
|
|
35
|
+
"""
|
|
36
|
+
Gets the current concurrency setting used by Patcher.
|
|
37
|
+
|
|
38
|
+
:return: The maximum number of concurrent API requests that can be made.
|
|
39
|
+
:rtype: int
|
|
40
|
+
"""
|
|
41
|
+
return self.max_concurrency
|
|
42
|
+
|
|
43
|
+
def set_concurrency(self, concurrency: int) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Sets the maximum concurrency level for API calls.
|
|
46
|
+
|
|
47
|
+
This method allows you to set the maximum number of concurrent API calls
|
|
48
|
+
that can be made by the Jamf client. It is recommended to limit this value
|
|
49
|
+
to 5 connections to avoid overloading the Jamf server.
|
|
50
|
+
|
|
51
|
+
:param concurrency: The new maximum concurrency level.
|
|
52
|
+
:type concurrency: int
|
|
53
|
+
:raises ValueError: If the concurrency level is less than 1.
|
|
54
|
+
"""
|
|
55
|
+
if concurrency < 1:
|
|
56
|
+
raise ValueError("Concurrency level must be at least 1.")
|
|
57
|
+
self.max_concurrency = concurrency
|
|
58
|
+
|
|
59
|
+
def _handle_status_code(
|
|
60
|
+
self, status_code: int, response_json: Optional[Dict]
|
|
61
|
+
) -> Optional[Dict]:
|
|
62
|
+
"""
|
|
63
|
+
Handles HTTP status codes and returns the appropriate response or raises errors.
|
|
64
|
+
|
|
65
|
+
:param status_code: The HTTP status code to evaluate.
|
|
66
|
+
:type status_code: int
|
|
67
|
+
:param response_json: The parsed JSON response from the API.
|
|
68
|
+
:type response_json: Optional[Dict]
|
|
69
|
+
:return: The response if JSON is successful, otherwise raises an exception.
|
|
70
|
+
:rtype: Optional[Dict]
|
|
71
|
+
"""
|
|
72
|
+
if 200 <= status_code < 300:
|
|
73
|
+
return response_json
|
|
74
|
+
elif 400 <= status_code < 500:
|
|
75
|
+
self.log.error(
|
|
76
|
+
f"Client error ({status_code}): {response_json.get('errors', 'Unknown error')}"
|
|
77
|
+
)
|
|
78
|
+
raise exceptions.APIResponseError(
|
|
79
|
+
f"Client error ({status_code}): {response_json.get('errors', 'Unknown error')}"
|
|
80
|
+
)
|
|
81
|
+
elif 500 <= status_code < 600:
|
|
82
|
+
self.log.error(
|
|
83
|
+
f"Server error ({status_code}): {response_json.get('errors', 'Unknown error')}"
|
|
84
|
+
)
|
|
85
|
+
raise exceptions.APIResponseError(
|
|
86
|
+
f"Server error ({status_code}): {response_json.get('errors', 'Unknown error')}"
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
self.log.error(f"Unexpected HTTP status code {status_code}: {response_json}")
|
|
90
|
+
raise exceptions.APIResponseError(
|
|
91
|
+
f"Unexpected HTTP status code {status_code}: {response_json}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _format_headers(headers: Dict[str, str]) -> List[str]:
|
|
96
|
+
"""
|
|
97
|
+
Formats headers properly for curl commands.
|
|
98
|
+
|
|
99
|
+
:param headers: Dictionary of headers to format.
|
|
100
|
+
:type headers: Dict[str, str]
|
|
101
|
+
:return: List of formatted headers.
|
|
102
|
+
:rtype: List[str]
|
|
103
|
+
"""
|
|
104
|
+
formatted_headers = []
|
|
105
|
+
for k, v in headers.items():
|
|
106
|
+
formatted_headers.extend(["-H", f"{k}: {v}"])
|
|
107
|
+
return formatted_headers
|
|
108
|
+
|
|
109
|
+
async def execute(self, command: List[str]) -> Optional[Union[Dict, str]]:
|
|
110
|
+
"""
|
|
111
|
+
Asynchronously executes a shell command using subprocess and returns the output.
|
|
112
|
+
|
|
113
|
+
This method leverages asyncio to run a command in a new subprocess. If the
|
|
114
|
+
command execution is unsuccessful (non-zero return code), an exception is raised.
|
|
115
|
+
|
|
116
|
+
.. note::
|
|
117
|
+
This method should be used for executing shell commands that are essential to the
|
|
118
|
+
functionality of the API client, such as invoking cURL commands for API calls.
|
|
119
|
+
|
|
120
|
+
:param command: A list representing the command and its arguments to be executed in the shell.
|
|
121
|
+
:type command: List[str]
|
|
122
|
+
:return: The standard output of the executed command decoded as a string, or None if there is an error.
|
|
123
|
+
:rtype: Optional[Union[Dict, str]]
|
|
124
|
+
:raises exceptions.ShellCommandError: If the command execution fails (returns a non-zero exit code).
|
|
125
|
+
"""
|
|
126
|
+
process = await asyncio.create_subprocess_exec(
|
|
127
|
+
*command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
128
|
+
)
|
|
129
|
+
stdout, stderr = await process.communicate()
|
|
130
|
+
if process.returncode != 0:
|
|
131
|
+
self.log.error(f"Error executing subprocess command: {stderr.decode()}")
|
|
132
|
+
raise exceptions.ShellCommandError(
|
|
133
|
+
reason=f"Error executing subprocess command: {stderr.decode()}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return stdout.decode()
|
|
137
|
+
|
|
138
|
+
async def fetch_json(
|
|
139
|
+
self,
|
|
140
|
+
url: str,
|
|
141
|
+
headers: Optional[Dict[str, str]] = None,
|
|
142
|
+
method: str = "GET",
|
|
143
|
+
data: Optional[Dict[str, str]] = None,
|
|
144
|
+
) -> Dict:
|
|
145
|
+
"""
|
|
146
|
+
Asynchronously fetches JSON data from the specified URL using a specified HTTP method.
|
|
147
|
+
|
|
148
|
+
:param url: The URL to fetch data from.
|
|
149
|
+
:type url: str
|
|
150
|
+
:param headers: Optional headers to include in the request.
|
|
151
|
+
:type headers: Optional[Dict[str, str]]
|
|
152
|
+
:param method: HTTP method to use ("GET" or "POST"). Defaults to "GET".
|
|
153
|
+
:type method: str
|
|
154
|
+
:param data: Optional form data to include for POST request.
|
|
155
|
+
:type data: Optional[Dict[str, str]]
|
|
156
|
+
:return: The fetched JSON data as a dictionary, or None if the request fails.
|
|
157
|
+
:rtype: Optional[Dict]
|
|
158
|
+
"""
|
|
159
|
+
final_headers = headers if headers else self.default_headers
|
|
160
|
+
header_string = self._format_headers(final_headers)
|
|
161
|
+
|
|
162
|
+
# By using the -w parameter with %{http_code}, we are appending the status code
|
|
163
|
+
# to the end of the API response. This is to handle cases where responses
|
|
164
|
+
# do not have 'httpStatus' keys by default.
|
|
165
|
+
command = [
|
|
166
|
+
"/usr/bin/curl",
|
|
167
|
+
"-s",
|
|
168
|
+
"-X",
|
|
169
|
+
method,
|
|
170
|
+
url,
|
|
171
|
+
*header_string,
|
|
172
|
+
"-w",
|
|
173
|
+
"\nSTATUS:%{http_code}",
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
# Add form data for POST requests
|
|
177
|
+
if method.upper() == "POST" and data:
|
|
178
|
+
if final_headers.get("Content-Type") == "application/x-www-form-urlencoded":
|
|
179
|
+
# Format each item separately instead
|
|
180
|
+
form_data = [item for k, v in data.items() for item in ["-d", f"{k}={v}"]]
|
|
181
|
+
command.extend(form_data)
|
|
182
|
+
else:
|
|
183
|
+
# JSON is assumed for other content types
|
|
184
|
+
json_payload = json.dumps(data)
|
|
185
|
+
command.extend(["-d", json_payload])
|
|
186
|
+
|
|
187
|
+
async with self.semaphore:
|
|
188
|
+
output = await self.execute(command)
|
|
189
|
+
|
|
190
|
+
# Separate status code from body of response
|
|
191
|
+
try:
|
|
192
|
+
response_body, status_line = output.rsplit("\nSTATUS:", 1)
|
|
193
|
+
status_code = int(status_line.strip())
|
|
194
|
+
response_json = json.loads(response_body) # Re-parse body as JSON
|
|
195
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
196
|
+
self.log.error(f"Failed to decode JSON or parse status code from response: {e}")
|
|
197
|
+
raise exceptions.APIResponseError(
|
|
198
|
+
f"Failed to decode JSON or parse status code from response: {e}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Handle status code from response
|
|
202
|
+
return self._handle_status_code(status_code, response_json)
|
|
203
|
+
|
|
204
|
+
async def fetch_batch(
|
|
205
|
+
self, urls: List[str], headers: Optional[Dict[str, str]] = None
|
|
206
|
+
) -> List[Optional[Dict]]:
|
|
207
|
+
"""
|
|
208
|
+
Fetches JSON data in batches to respect the concurrency limit. Data is fetched
|
|
209
|
+
from each URL in the provided list, ensuring that no more than ``max_concurrency``
|
|
210
|
+
requests are sent concurrently.
|
|
211
|
+
|
|
212
|
+
:param urls: List of URLs to fetch data from.
|
|
213
|
+
:type urls: List[str]
|
|
214
|
+
:param headers:
|
|
215
|
+
:type headers: Optional[Dict[str, str]] = None
|
|
216
|
+
:return: A list of JSON dictionaries or None for URLs that fail to retrieve data.
|
|
217
|
+
:rtype: List[Optional[Dict]]
|
|
218
|
+
"""
|
|
219
|
+
results = []
|
|
220
|
+
for i in range(0, len(urls), self.max_concurrency):
|
|
221
|
+
batch = urls[i : i + self.max_concurrency]
|
|
222
|
+
tasks = [self.fetch_json(url, headers=headers) for url in batch]
|
|
223
|
+
batch_results = await asyncio.gather(*tasks)
|
|
224
|
+
results.extend(batch_results)
|
|
225
|
+
return results
|
|
226
|
+
|
|
227
|
+
# API calls for client setup
|
|
228
|
+
async def fetch_basic_token(self, username: str, password: str, jamf_url: str) -> Optional[str]:
|
|
229
|
+
"""
|
|
230
|
+
Asynchronously retrieves a bearer token using basic authentication.
|
|
231
|
+
|
|
232
|
+
This method is intended for initial setup to obtain client credentials for API clients and roles.
|
|
233
|
+
It should not be used for regular token retrieval after setup.
|
|
234
|
+
|
|
235
|
+
:param username: Username of admin Jamf Pro account for authentication. Not permanently stored, only used for initial token retrieval.
|
|
236
|
+
:type username: str
|
|
237
|
+
:param password: Password of admin Jamf Pro account. Not permanently stored, only used for initial token retrieval.
|
|
238
|
+
:type password: str
|
|
239
|
+
:param jamf_url: Jamf Server URL (same as ``server_url`` in :mod:`patcher.models.jamf_client` class).
|
|
240
|
+
:type jamf_url: str
|
|
241
|
+
:raises exceptions.TokenFetchError: If the call is unauthorized or unsuccessful.
|
|
242
|
+
:returns: True if the basic token was successfully retrieved, False if unauthorized (e.g., due to SSO).
|
|
243
|
+
:rtype: bool
|
|
244
|
+
"""
|
|
245
|
+
token_url = f"{jamf_url}/api/v1/auth/token"
|
|
246
|
+
command = [
|
|
247
|
+
"/usr/bin/curl",
|
|
248
|
+
"-s",
|
|
249
|
+
"-u",
|
|
250
|
+
f"{username}:{password}",
|
|
251
|
+
"-H",
|
|
252
|
+
"accept: application/json",
|
|
253
|
+
"-X",
|
|
254
|
+
"POST",
|
|
255
|
+
token_url,
|
|
256
|
+
]
|
|
257
|
+
async with self.semaphore:
|
|
258
|
+
resp = await self.execute(command)
|
|
259
|
+
response = json.loads(resp)
|
|
260
|
+
if response and "token" in response:
|
|
261
|
+
return response.get("token")
|
|
262
|
+
else:
|
|
263
|
+
raise exceptions.TokenFetchError(
|
|
264
|
+
f"Unable to retrieve basic token with provided username ({username}) and password"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def create_roles(self, token: str, jamf_url: str) -> bool:
|
|
268
|
+
"""
|
|
269
|
+
Creates the necessary API roles using the provided bearer token.
|
|
270
|
+
|
|
271
|
+
:param token: The bearer token to use for authentication. Defaults to the stored token if not provided.
|
|
272
|
+
:type token: Optional[str]
|
|
273
|
+
:param jamf_url: Jamf Server URL
|
|
274
|
+
:type jamf_url: str
|
|
275
|
+
:return: True if roles were successfully created, False otherwise.
|
|
276
|
+
:rtype: bool
|
|
277
|
+
"""
|
|
278
|
+
role = ApiRoleModel()
|
|
279
|
+
payload = {
|
|
280
|
+
"displayName": role.display_name,
|
|
281
|
+
"privileges": role.privileges,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
role_url = f"{jamf_url}/api/v1/api-roles"
|
|
285
|
+
headers = {
|
|
286
|
+
"accept": "application/json",
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
"Authorization": f"Bearer {token}",
|
|
289
|
+
}
|
|
290
|
+
response = await self.fetch_json(url=role_url, headers=headers, method="POST", data=payload)
|
|
291
|
+
return response.get("displayName") == role.display_name
|
|
292
|
+
|
|
293
|
+
async def create_client(self, token: str, jamf_url: str) -> Optional[Tuple[str, str]]:
|
|
294
|
+
"""
|
|
295
|
+
Creates an API client and retrieves its client ID and client secret.
|
|
296
|
+
|
|
297
|
+
:param token: The bearer token to use for authentication. Defaults to the stored token if not provided.
|
|
298
|
+
:type token: Optional[str]
|
|
299
|
+
:param jamf_url: Jamf Server URL
|
|
300
|
+
:type jamf_url: str
|
|
301
|
+
:return: A tuple containing the client ID and client secret.
|
|
302
|
+
:rtype: Optional[Tuple[str, str]]
|
|
303
|
+
"""
|
|
304
|
+
client = ApiClientModel()
|
|
305
|
+
client_url = f"{jamf_url}/api/v1/api-integrations"
|
|
306
|
+
payload = {
|
|
307
|
+
"authorizationScopes": client.auth_scopes,
|
|
308
|
+
"displayName": client.display_name,
|
|
309
|
+
"enabled": client.enabled,
|
|
310
|
+
"accessTokenLifetimeSeconds": client.token_lifetime, # 30 minutes in seconds
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
headers = {
|
|
314
|
+
"accept": "application/json",
|
|
315
|
+
"Content-Type": "application/json",
|
|
316
|
+
"Authorization": f"Bearer {token}",
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
response = await self.fetch_json(
|
|
320
|
+
url=client_url, method="POST", data=payload, headers=headers
|
|
321
|
+
)
|
|
322
|
+
if not response.get("clientId"):
|
|
323
|
+
raise exceptions.SetupError("Failed creating client ID!")
|
|
324
|
+
|
|
325
|
+
client_id = response.get("clientId")
|
|
326
|
+
integration_id = response.get("id")
|
|
327
|
+
|
|
328
|
+
secret_url = f"{jamf_url}/api/v1/api-integrations/{integration_id}/client-credentials"
|
|
329
|
+
secret_response = await self.fetch_json(url=secret_url, method="POST", headers=headers)
|
|
330
|
+
|
|
331
|
+
if not secret_response.get("clientSecret"):
|
|
332
|
+
raise exceptions.SetupError(f"Failed creating client secret for {client_id}")
|
|
333
|
+
|
|
334
|
+
client_secret = secret_response.get("clientSecret")
|
|
335
|
+
return client_id, client_secret
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ..models.patch import PatchTitle
|
|
7
|
+
from ..utils import exceptions, logger
|
|
8
|
+
|
|
9
|
+
# TODO
|
|
10
|
+
# 3. Analysis based on PatchTitle criteria (completion percent, release date, etc.)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Analyzer:
|
|
14
|
+
def __init__(self, csv_path: Union[Path, str]):
|
|
15
|
+
self.log = logger.LogMe(self.__class__.__name__)
|
|
16
|
+
self.patch_titles: List[PatchTitle] = []
|
|
17
|
+
self.df = self.initialize_dataframe(csv_path)
|
|
18
|
+
|
|
19
|
+
def initialize_dataframe(self, csv_path: Union[Path, str]) -> pd.DataFrame:
|
|
20
|
+
"""
|
|
21
|
+
Initializes a DataFrame by reading the CSV file from the provided path.
|
|
22
|
+
|
|
23
|
+
:param csv_path: The path to the CSV file, either as a string or a pathlib.Path object.
|
|
24
|
+
:type csv_path: Union[Path, str]
|
|
25
|
+
:return: A pandas DataFrame loaded from the CSV file.
|
|
26
|
+
:rtype: pd.DatFrame
|
|
27
|
+
"""
|
|
28
|
+
csv_path = Path(csv_path)
|
|
29
|
+
|
|
30
|
+
if not csv_path.exists():
|
|
31
|
+
raise exceptions.PatcherError(message=f"The file at path {csv_path} does not exist.")
|
|
32
|
+
if not csv_path.is_file():
|
|
33
|
+
raise exceptions.PatcherError(message=f"The path {csv_path} is not a file.")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
df = pd.read_csv(csv_path)
|
|
37
|
+
self.log.info(f"DataFrame successfully initialized from {csv_path}.")
|
|
38
|
+
return df
|
|
39
|
+
except PermissionError as e:
|
|
40
|
+
raise exceptions.DataframeError(
|
|
41
|
+
reason=f"Permission denied when trying to read {csv_path}: {e}"
|
|
42
|
+
)
|
|
43
|
+
except pd.errors.EmptyDataError as e:
|
|
44
|
+
raise exceptions.DataframeError(reason=f"The file at {csv_path} is empty. Details: {e}")
|
|
45
|
+
except pd.errors.ParserError as e:
|
|
46
|
+
raise exceptions.DataframeError(
|
|
47
|
+
reason=f"Failed to parse the CSV file at {csv_path}: {e}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def format_table(data: List[List[str]], headers: Optional[List[str]] = None) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Formats the data passed into a table for CLI output.
|
|
54
|
+
|
|
55
|
+
:param data: The data to display in the table.
|
|
56
|
+
:type data: List[List[str]]
|
|
57
|
+
:param headers: Header names for the columns of the tables.
|
|
58
|
+
:type headers: Optional[List[str]]
|
|
59
|
+
:return: The formatted table as a string.
|
|
60
|
+
:rtype: str
|
|
61
|
+
"""
|
|
62
|
+
if headers:
|
|
63
|
+
data = [headers] + data
|
|
64
|
+
|
|
65
|
+
column_widths = [max(len(str(item)) for item in column) for column in zip(*data)]
|
|
66
|
+
format_string = " | ".join([f"{{:<{width}}}" for width in column_widths])
|
|
67
|
+
table = [format_string.format(*row) for row in data]
|
|
68
|
+
|
|
69
|
+
if headers:
|
|
70
|
+
header_separator = "-+-".join("-" * width for width in column_widths)
|
|
71
|
+
table.insert(1, header_separator)
|
|
72
|
+
|
|
73
|
+
return "\n".join(table)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
async def print_table(
|
|
77
|
+
cls,
|
|
78
|
+
patch_titles: List[PatchTitle],
|
|
79
|
+
criteria: str,
|
|
80
|
+
) -> str:
|
|
81
|
+
# TODO
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def filter_titles(
|
|
86
|
+
cls,
|
|
87
|
+
patch_titles: List[PatchTitle],
|
|
88
|
+
criteria: str,
|
|
89
|
+
threshold: Optional[float] = 70.0,
|
|
90
|
+
top_n: Optional[int] = 3,
|
|
91
|
+
) -> List[PatchTitle]:
|
|
92
|
+
"""
|
|
93
|
+
Filters and sorts PatchTitle objects based on specified criteria.
|
|
94
|
+
|
|
95
|
+
:param patch_titles: A list of PatchTitle objects.
|
|
96
|
+
:type patch_titles: List[PatchTitle]
|
|
97
|
+
:param criteria: The criteria to filter and sort by. Options are:
|
|
98
|
+
- 'most_installed': Returns the top N most installed software by total_hosts.
|
|
99
|
+
- 'least_installed': Returns the top N least installed software by total_hosts.
|
|
100
|
+
- 'oldest_least_complete': Returns the top N oldest patches with the least completion percent.
|
|
101
|
+
- 'below_threshold': Returns patches below a certain completion percentage.
|
|
102
|
+
:type criteria: str
|
|
103
|
+
:param threshold: The threshold for filtering completion percentages, default is 70.0.
|
|
104
|
+
:type threshold: Optional[float]
|
|
105
|
+
:param top_n: The number of results to return, default is 3.
|
|
106
|
+
:type top_n: Optional[int]
|
|
107
|
+
:return: A list of filtered and sorted PatchTitle objects.
|
|
108
|
+
:rtype: List[PatchTitle]
|
|
109
|
+
"""
|
|
110
|
+
sort_criteria: dict[str, Callable[[List[PatchTitle]], List[PatchTitle]]] = {
|
|
111
|
+
"most_installed": lambda patches: sorted(
|
|
112
|
+
patches, key=lambda pt: pt.total_hosts, reverse=True
|
|
113
|
+
)[:top_n],
|
|
114
|
+
"least_installed": lambda patches: sorted(patches, key=lambda pt: pt.total_hosts)[
|
|
115
|
+
:top_n
|
|
116
|
+
],
|
|
117
|
+
"oldest_least_complete": lambda patches: sorted(
|
|
118
|
+
patches, key=lambda pt: (pt.released, pt.completion_percent)
|
|
119
|
+
)[:top_n],
|
|
120
|
+
"below_threshold": lambda patches: sorted(
|
|
121
|
+
[pt for pt in patches if pt.completion_percent < threshold],
|
|
122
|
+
key=lambda pt: pt.completion_percent,
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Check for valid criteria
|
|
127
|
+
if criteria not in sort_criteria:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Invalid criteria '{criteria}'. Supported criteria: {', '.join(sort_criteria.keys())}."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Apply sorting/filtering strategy
|
|
133
|
+
return sort_criteria[criteria](patch_titles)
|