libre-devops-helpers 0.4.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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""HTTP client for Microsoft APIs: bearer auth, bounded retries, Retry-After, paging.
|
|
2
|
+
|
|
3
|
+
Built on requests. Retries are decided on the HTTP status code (408, 429, 5xx) and on
|
|
4
|
+
connection errors or timeouts, never on the wording of an error message. The bearer
|
|
5
|
+
token is only ever sent to the client's own https host, and redirects are not followed.
|
|
6
|
+
Every POST this package makes is a read-only query or a token request, so retrying
|
|
7
|
+
one is safe. A 401 is retried once with a fresh token, when the token source can drop
|
|
8
|
+
the one it cached (``core.auth.BearerToken``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import email.utils
|
|
14
|
+
import ipaddress
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from typing import Any, Self
|
|
22
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from libre_devops_helpers import __version__
|
|
27
|
+
from libre_devops_helpers.core import brand
|
|
28
|
+
from libre_devops_helpers.core.errors import ApiError
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
|
|
33
|
+
USER_AGENT = f"{brand.COMMAND}/{__version__}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiClient:
|
|
37
|
+
"""JSON client for one API base URL, usually authenticated with a bearer token.
|
|
38
|
+
|
|
39
|
+
``token`` is called before every request, so a caching provider can refresh a token
|
|
40
|
+
close to expiry. If it also has a ``refresh()`` method, a 401 answer drops the cached
|
|
41
|
+
token and the request is sent once more with a new one: that covers a token revoked
|
|
42
|
+
or expired early. Tokens go in the Authorization header only and are never logged.
|
|
43
|
+
With ``token=None`` no Authorization header is sent; only then may ``allow_http``
|
|
44
|
+
permit a plain http base URL on a loopback or link-local host, which is what the
|
|
45
|
+
managed identity endpoints use.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
base_url: str,
|
|
51
|
+
token: Callable[[], str] | None,
|
|
52
|
+
*,
|
|
53
|
+
name: str = "API",
|
|
54
|
+
session: requests.Session | None = None,
|
|
55
|
+
verify: bool | str = True,
|
|
56
|
+
timeout: float = 30.0,
|
|
57
|
+
max_attempts: int = 4,
|
|
58
|
+
backoff: float = 1.0,
|
|
59
|
+
max_backoff: float = 30.0,
|
|
60
|
+
max_retry_after: float = 120.0,
|
|
61
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
62
|
+
allow_http: bool = False,
|
|
63
|
+
auth_scheme: str = "Bearer",
|
|
64
|
+
) -> None:
|
|
65
|
+
parts = urlsplit(base_url)
|
|
66
|
+
if not parts.netloc or parts.scheme not in {"https", "http"}:
|
|
67
|
+
raise ValueError(f"base_url must be an https URL, got {base_url!r}")
|
|
68
|
+
if parts.scheme == "http" and not (
|
|
69
|
+
allow_http and token is None and _is_local(parts.hostname or "")
|
|
70
|
+
):
|
|
71
|
+
raise ValueError(f"base_url must be an https URL, got {base_url!r}")
|
|
72
|
+
if max_attempts < 1:
|
|
73
|
+
raise ValueError("max_attempts must be at least 1")
|
|
74
|
+
self.name = name
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
self._scheme = parts.scheme
|
|
77
|
+
self._host = parts.netloc.lower()
|
|
78
|
+
self._token = token
|
|
79
|
+
# "Bearer" for tokens; "Basic" when ``token`` returns base64 "user:password".
|
|
80
|
+
self._auth_scheme = auth_scheme
|
|
81
|
+
refresh = getattr(token, "refresh", None)
|
|
82
|
+
self._refresh_token: Callable[[], None] | None = refresh if callable(refresh) else None
|
|
83
|
+
self._session = session or requests.Session()
|
|
84
|
+
self._owns_session = session is None
|
|
85
|
+
self._verify = verify
|
|
86
|
+
self._timeout = timeout
|
|
87
|
+
self._max_attempts = max_attempts
|
|
88
|
+
self._backoff = backoff
|
|
89
|
+
self._max_backoff = max_backoff
|
|
90
|
+
self._max_retry_after = max_retry_after
|
|
91
|
+
self._sleep = sleep
|
|
92
|
+
|
|
93
|
+
def ensure_token(self) -> None:
|
|
94
|
+
"""Get the token now, in this thread.
|
|
95
|
+
|
|
96
|
+
Call it on the main thread before fanning requests out to workers, so that a
|
|
97
|
+
credential which has to ask someone to sign in again asks there.
|
|
98
|
+
"""
|
|
99
|
+
if self._token is not None:
|
|
100
|
+
self._token()
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
"""Close the underlying session if this client created it."""
|
|
104
|
+
if self._owns_session:
|
|
105
|
+
self._session.close()
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> Self:
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
111
|
+
self.close()
|
|
112
|
+
|
|
113
|
+
def url(self, path: str, params: Mapping[str, str] | None = None) -> str:
|
|
114
|
+
"""Absolute URL for ``path`` with ``params`` percent-encoded.
|
|
115
|
+
|
|
116
|
+
``path`` may be a full URL (an ``@odata.nextLink``); it must use this client's
|
|
117
|
+
scheme and host, so a token never travels anywhere else.
|
|
118
|
+
"""
|
|
119
|
+
if "://" in path:
|
|
120
|
+
parts = urlsplit(path)
|
|
121
|
+
if parts.scheme != self._scheme or parts.netloc.lower() != self._host:
|
|
122
|
+
raise ApiError(
|
|
123
|
+
f"{self.name}: refusing to send a token to {parts.scheme}://{parts.netloc}"
|
|
124
|
+
)
|
|
125
|
+
url = path
|
|
126
|
+
else:
|
|
127
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
128
|
+
if params:
|
|
129
|
+
url += ("&" if "?" in url else "?") + urlencode(params, quote_via=quote)
|
|
130
|
+
return url
|
|
131
|
+
|
|
132
|
+
def request(
|
|
133
|
+
self,
|
|
134
|
+
method: str,
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
params: Mapping[str, str] | None = None,
|
|
138
|
+
headers: Mapping[str, str] | None = None,
|
|
139
|
+
json_body: Any = None,
|
|
140
|
+
form: Mapping[str, str] | None = None,
|
|
141
|
+
allow_empty: bool = False,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Send one request and return the JSON object it responds with.
|
|
144
|
+
|
|
145
|
+
``allow_empty`` accepts a success with no body (some actions answer 200 or 204
|
|
146
|
+
with nothing), returning ``{}`` for it.
|
|
147
|
+
"""
|
|
148
|
+
url = self.url(path, params)
|
|
149
|
+
response = self._send(method, url, headers, json_body=json_body, form=form)
|
|
150
|
+
if allow_empty and not response.content.strip():
|
|
151
|
+
return {}
|
|
152
|
+
try:
|
|
153
|
+
body = response.json()
|
|
154
|
+
except ValueError:
|
|
155
|
+
raise ApiError(
|
|
156
|
+
f"{self.name}: {method} {_path(url)} did not return JSON",
|
|
157
|
+
status=response.status_code,
|
|
158
|
+
) from None
|
|
159
|
+
if not isinstance(body, dict):
|
|
160
|
+
raise ApiError(f"{self.name}: {method} {_path(url)} did not return a JSON object")
|
|
161
|
+
return body
|
|
162
|
+
|
|
163
|
+
def get(
|
|
164
|
+
self,
|
|
165
|
+
path: str,
|
|
166
|
+
*,
|
|
167
|
+
params: Mapping[str, str] | None = None,
|
|
168
|
+
headers: Mapping[str, str] | None = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""GET ``path`` and return the JSON object it responds with."""
|
|
171
|
+
return self.request("GET", path, params=params, headers=headers)
|
|
172
|
+
|
|
173
|
+
def get_text(
|
|
174
|
+
self,
|
|
175
|
+
path: str,
|
|
176
|
+
*,
|
|
177
|
+
params: Mapping[str, str] | None = None,
|
|
178
|
+
headers: Mapping[str, str] | None = None,
|
|
179
|
+
) -> str:
|
|
180
|
+
"""GET ``path`` and return its body as text, for the few APIs that answer in text."""
|
|
181
|
+
response = self._send("GET", self.url(path, params), headers)
|
|
182
|
+
return response.text
|
|
183
|
+
|
|
184
|
+
def post(
|
|
185
|
+
self,
|
|
186
|
+
path: str,
|
|
187
|
+
body: Any,
|
|
188
|
+
*,
|
|
189
|
+
params: Mapping[str, str] | None = None,
|
|
190
|
+
headers: Mapping[str, str] | None = None,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""POST ``body`` as JSON to ``path`` and return the JSON object it responds with."""
|
|
193
|
+
return self.request("POST", path, params=params, headers=headers, json_body=body)
|
|
194
|
+
|
|
195
|
+
def get_all(
|
|
196
|
+
self,
|
|
197
|
+
path: str,
|
|
198
|
+
*,
|
|
199
|
+
params: Mapping[str, str] | None = None,
|
|
200
|
+
headers: Mapping[str, str] | None = None,
|
|
201
|
+
next_link: str = "@odata.nextLink",
|
|
202
|
+
) -> Iterator[dict[str, Any]]:
|
|
203
|
+
"""Yield every item of a paged collection, following ``next_link``.
|
|
204
|
+
|
|
205
|
+
Graph and Defender use ``@odata.nextLink``; Azure Resource Manager and Key Vault
|
|
206
|
+
use ``nextLink``. Pages are fetched only as items are consumed.
|
|
207
|
+
"""
|
|
208
|
+
page = self.get(path, params=params, headers=headers)
|
|
209
|
+
while True:
|
|
210
|
+
items = page.get("value")
|
|
211
|
+
if not isinstance(items, list):
|
|
212
|
+
raise ApiError(f"{self.name}: response has no 'value' array")
|
|
213
|
+
yield from (item for item in items if isinstance(item, dict))
|
|
214
|
+
link = page.get(next_link)
|
|
215
|
+
if not isinstance(link, str) or not link:
|
|
216
|
+
return
|
|
217
|
+
page = self.get(link, headers=headers)
|
|
218
|
+
|
|
219
|
+
def _send(
|
|
220
|
+
self,
|
|
221
|
+
method: str,
|
|
222
|
+
url: str,
|
|
223
|
+
headers: Mapping[str, str] | None,
|
|
224
|
+
*,
|
|
225
|
+
json_body: Any = None,
|
|
226
|
+
form: Mapping[str, str] | None = None,
|
|
227
|
+
) -> requests.Response:
|
|
228
|
+
attempt = 0
|
|
229
|
+
refreshed = False
|
|
230
|
+
while True:
|
|
231
|
+
attempt += 1
|
|
232
|
+
request_headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
233
|
+
if self._token is not None:
|
|
234
|
+
request_headers["Authorization"] = f"{self._auth_scheme} {self._token()}"
|
|
235
|
+
request_headers.update(headers or {})
|
|
236
|
+
try:
|
|
237
|
+
response = self._session.request(
|
|
238
|
+
method,
|
|
239
|
+
url,
|
|
240
|
+
headers=request_headers,
|
|
241
|
+
json=json_body,
|
|
242
|
+
data=form,
|
|
243
|
+
timeout=self._timeout,
|
|
244
|
+
verify=self._verify,
|
|
245
|
+
allow_redirects=False,
|
|
246
|
+
)
|
|
247
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
248
|
+
if attempt >= self._max_attempts:
|
|
249
|
+
raise ApiError(
|
|
250
|
+
f"{self.name}: {method} {_path(url)} failed after {attempt} attempts: "
|
|
251
|
+
f"{type(exc).__name__}"
|
|
252
|
+
) from None
|
|
253
|
+
self._wait(attempt, None, type(exc).__name__)
|
|
254
|
+
continue
|
|
255
|
+
except requests.RequestException as exc:
|
|
256
|
+
raise ApiError(f"{self.name}: {method} {_path(url)} failed: {exc}") from None
|
|
257
|
+
|
|
258
|
+
if 200 <= response.status_code < 300:
|
|
259
|
+
return response
|
|
260
|
+
if response.status_code == 401 and self._refresh_token and not refreshed:
|
|
261
|
+
# Once only: a second 401 means the token is not the problem.
|
|
262
|
+
refreshed = True
|
|
263
|
+
attempt -= 1
|
|
264
|
+
log.info("%s: HTTP 401, retrying once with a new token", self.name)
|
|
265
|
+
self._refresh_token()
|
|
266
|
+
continue
|
|
267
|
+
if response.status_code in RETRY_STATUSES and attempt < self._max_attempts:
|
|
268
|
+
self._wait(attempt, retry_after_seconds(response), f"HTTP {response.status_code}")
|
|
269
|
+
continue
|
|
270
|
+
raise error_from_response(self.name, response)
|
|
271
|
+
|
|
272
|
+
def _wait(self, attempt: int, retry_after: float | None, reason: str) -> None:
|
|
273
|
+
# A server-directed Retry-After wins over the backoff (retrying earlier just
|
|
274
|
+
# throttles again), capped so a broken server cannot stall the run.
|
|
275
|
+
if retry_after is not None:
|
|
276
|
+
delay = min(self._max_retry_after, retry_after)
|
|
277
|
+
else:
|
|
278
|
+
delay = min(self._max_backoff, self._backoff * 2 ** (attempt - 1))
|
|
279
|
+
delay += random.uniform(0, self._backoff / 2)
|
|
280
|
+
log.warning(
|
|
281
|
+
"%s: %s on attempt %d of %d, retrying in %.1fs",
|
|
282
|
+
self.name,
|
|
283
|
+
reason,
|
|
284
|
+
attempt,
|
|
285
|
+
self._max_attempts,
|
|
286
|
+
delay,
|
|
287
|
+
)
|
|
288
|
+
self._sleep(delay)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def retry_after_seconds(response: requests.Response) -> float | None:
|
|
292
|
+
"""Seconds requested by a Retry-After header (delta-seconds or HTTP date), else None."""
|
|
293
|
+
value = response.headers.get("Retry-After", "").strip()
|
|
294
|
+
if not value:
|
|
295
|
+
return None
|
|
296
|
+
if value.isdigit():
|
|
297
|
+
return float(value)
|
|
298
|
+
try:
|
|
299
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
300
|
+
except (TypeError, ValueError):
|
|
301
|
+
return None
|
|
302
|
+
if when.tzinfo is None:
|
|
303
|
+
when = when.replace(tzinfo=UTC)
|
|
304
|
+
return max(0.0, (when - datetime.now(UTC)).total_seconds())
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def error_from_response(name: str, response: requests.Response) -> ApiError:
|
|
308
|
+
"""Build an ApiError from a failed response, using the service's error body when present.
|
|
309
|
+
|
|
310
|
+
Graph, Defender, ARM and Key Vault reply ``{"error": {"code", "message"}}``; the Entra
|
|
311
|
+
token endpoint replies ``{"error": "<code>", "error_description": "..."}``.
|
|
312
|
+
"""
|
|
313
|
+
code: str | None = None
|
|
314
|
+
message: str | None = None
|
|
315
|
+
request_id: str | None = None
|
|
316
|
+
try:
|
|
317
|
+
body = response.json()
|
|
318
|
+
except ValueError:
|
|
319
|
+
body = None
|
|
320
|
+
if isinstance(body, dict) and isinstance(body.get("error"), dict):
|
|
321
|
+
error = body["error"]
|
|
322
|
+
code = error.get("code") if isinstance(error.get("code"), str) else None
|
|
323
|
+
message = error.get("message") if isinstance(error.get("message"), str) else None
|
|
324
|
+
inner = error.get("innerError")
|
|
325
|
+
if isinstance(inner, dict) and isinstance(inner.get("request-id"), str):
|
|
326
|
+
request_id = inner["request-id"]
|
|
327
|
+
elif isinstance(body, dict) and isinstance(body.get("error"), str):
|
|
328
|
+
code = body["error"]
|
|
329
|
+
description = body.get("error_description")
|
|
330
|
+
if isinstance(description, str) and description.strip():
|
|
331
|
+
# The first line carries the AADSTS code and the reason; the rest is trace ids.
|
|
332
|
+
message = description.strip().splitlines()[0]
|
|
333
|
+
request_id = (
|
|
334
|
+
request_id or response.headers.get("request-id") or response.headers.get("x-ms-request-id")
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
status = response.status_code
|
|
338
|
+
detail = _readable(message) if message else ""
|
|
339
|
+
text = f"{name}: HTTP {status}"
|
|
340
|
+
if code:
|
|
341
|
+
text += f" {code}"
|
|
342
|
+
text += f": {detail or response.reason or 'request failed'}"
|
|
343
|
+
if request_id:
|
|
344
|
+
text += f" (request-id {request_id})"
|
|
345
|
+
challenge = response.headers.get("WWW-Authenticate", "").lower()
|
|
346
|
+
if status == 401 and "insufficient_claims" in challenge:
|
|
347
|
+
# Continuous access evaluation: the token was revoked, or a policy changed.
|
|
348
|
+
hint: str | None = (
|
|
349
|
+
"the service revoked the token or wants a sign-in that meets a Conditional "
|
|
350
|
+
"Access policy (a claims challenge); sign in again"
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
hint = _hint(status, text.lower())
|
|
354
|
+
return ApiError(text, status=status, code=code, request_id=request_id, hint=hint)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _readable(message: str) -> str:
|
|
358
|
+
"""One readable line from a service error message.
|
|
359
|
+
|
|
360
|
+
Some services put a JSON document inside the message (Graph's PIM errors, Intune,
|
|
361
|
+
which nests two deep); those are unwrapped to their inner code and message. Line
|
|
362
|
+
breaks are flattened, and the result is capped so one error cannot flood a terminal.
|
|
363
|
+
"""
|
|
364
|
+
codes: list[str] = []
|
|
365
|
+
text = message.strip()
|
|
366
|
+
for _ in range(3):
|
|
367
|
+
if not text.startswith("{"):
|
|
368
|
+
break
|
|
369
|
+
try:
|
|
370
|
+
inner = json.loads(text)
|
|
371
|
+
except ValueError:
|
|
372
|
+
break
|
|
373
|
+
if not isinstance(inner, dict):
|
|
374
|
+
break
|
|
375
|
+
code = inner.get("errorCode") or inner.get("ErrorCode") or inner.get("code")
|
|
376
|
+
if isinstance(code, str) and code:
|
|
377
|
+
codes.append(code)
|
|
378
|
+
found = inner.get("message") or inner.get("Message")
|
|
379
|
+
if not isinstance(found, str):
|
|
380
|
+
text = ""
|
|
381
|
+
break
|
|
382
|
+
text = found.strip()
|
|
383
|
+
flat = " ".join(text.split())
|
|
384
|
+
readable = ": ".join([*codes, flat] if flat else codes)
|
|
385
|
+
return readable if len(readable) <= 400 else readable[:397] + "..."
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _hint(status: int, text: str) -> str | None:
|
|
389
|
+
if status == 403 and "suspended" in text:
|
|
390
|
+
return "the service is suspended in this tenant, usually because its licence or trial ended"
|
|
391
|
+
if status == 403 and "client address is not authorized" in text:
|
|
392
|
+
return "the resource's firewall does not allow this machine's IP address"
|
|
393
|
+
if status in {401, 403} and ("forbidden" in text or "permission" in text):
|
|
394
|
+
return "the token was accepted but lacks the permission this call needs"
|
|
395
|
+
return {
|
|
396
|
+
401: "the API rejected the token; check its audience, tenant and expiry",
|
|
397
|
+
403: "the signed-in identity lacks a role or permission for this call",
|
|
398
|
+
}.get(status)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _is_local(host: str) -> bool:
|
|
402
|
+
if host == "localhost":
|
|
403
|
+
return True
|
|
404
|
+
try:
|
|
405
|
+
address = ipaddress.ip_address(host)
|
|
406
|
+
except ValueError:
|
|
407
|
+
return False
|
|
408
|
+
return address.is_loopback or address.is_link_local
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _path(url: str) -> str:
|
|
412
|
+
return urlsplit(url).path
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Read a list of names from arguments, stdin, a text file, a CSV or an Excel workbook.
|
|
2
|
+
|
|
3
|
+
Commands that take devices, users or vaults accept them however they are to hand:
|
|
4
|
+
``"a,b,c"``, several arguments, ``-`` for stdin, or a file. A text file holds names
|
|
5
|
+
separated by commas, spaces or new lines, with ``#`` comments. A CSV file (a ``.csv``
|
|
6
|
+
suffix, or any file when a column is named) and an Excel workbook (``.xlsx``, ``.xlsm``,
|
|
7
|
+
``.xltx``, ``.xltm``) are read by column header, so a plan, an export from a portal or a
|
|
8
|
+
spreadsheet someone emailed works as it is, title rows above the header and all.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import io
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Iterable, Sequence
|
|
17
|
+
from itertools import islice
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
|
|
21
|
+
from libre_devops_helpers.core import sheets
|
|
22
|
+
from libre_devops_helpers.core.errors import InputError
|
|
23
|
+
from libre_devops_helpers.core.util import split_names
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# With a column named, the header is the first of this many non-blank rows to have it.
|
|
28
|
+
HEADER_SEARCH_ROWS = 25
|
|
29
|
+
|
|
30
|
+
# Cells of one row, and whether the row is hidden (in Excel; a CSV row never is).
|
|
31
|
+
Row = tuple[Sequence[str], bool]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_names(
|
|
35
|
+
values: Sequence[str] = (),
|
|
36
|
+
*,
|
|
37
|
+
stdin: TextIO | None = None,
|
|
38
|
+
from_file: Path | None = None,
|
|
39
|
+
column: str | None = None,
|
|
40
|
+
sheet: str | None = None,
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
"""Every name given, in order, with blanks and case-insensitive repeats dropped.
|
|
43
|
+
|
|
44
|
+
``-`` among ``values`` reads ``stdin`` in its place. ``column`` picks a column (by
|
|
45
|
+
header, case-insensitively) of a CSV or workbook ``from_file``, or of CSV on stdin
|
|
46
|
+
when there is no file. ``sheet`` picks a workbook's sheet by tab name; without it,
|
|
47
|
+
the one visible sheet with that column is used, or the first visible sheet when no
|
|
48
|
+
column is named.
|
|
49
|
+
"""
|
|
50
|
+
if sheet is not None and (from_file is None or not sheets.is_workbook(from_file)):
|
|
51
|
+
raise InputError("--sheet applies to an Excel workbook only")
|
|
52
|
+
collected: list[str] = []
|
|
53
|
+
for value in values:
|
|
54
|
+
if value == "-":
|
|
55
|
+
if stdin is None:
|
|
56
|
+
raise InputError("'-' reads names from stdin, but there is no stdin")
|
|
57
|
+
text = stdin.read()
|
|
58
|
+
collected.extend(_from_csv(text, column, "stdin") if column else _from_text(text))
|
|
59
|
+
else:
|
|
60
|
+
collected.extend(split_names([value]))
|
|
61
|
+
if from_file is not None:
|
|
62
|
+
collected.extend(_from_file(from_file, column, sheet))
|
|
63
|
+
return _dedupe(collected)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _from_file(path: Path, column: str | None, sheet: str | None) -> list[str]:
|
|
67
|
+
sheets.check_readable(path)
|
|
68
|
+
if sheets.is_workbook(path):
|
|
69
|
+
return _from_workbook(path, column, sheet)
|
|
70
|
+
try:
|
|
71
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
raise InputError(
|
|
74
|
+
f"{path} is not a text file", hint="use a text file, a CSV or an Excel workbook"
|
|
75
|
+
) from None
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise InputError(f"cannot read {path}: {exc}") from None
|
|
78
|
+
if column or path.suffix.lower() == ".csv":
|
|
79
|
+
return _from_csv(text, column, str(path))
|
|
80
|
+
return _from_text(text)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _from_text(text: str) -> list[str]:
|
|
84
|
+
return split_names(line.split("#", 1)[0] for line in text.splitlines())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dedupe(names: list[str]) -> list[str]:
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
kept: list[str] = []
|
|
90
|
+
for name in names:
|
|
91
|
+
if name.casefold() not in seen:
|
|
92
|
+
seen.add(name.casefold())
|
|
93
|
+
kept.append(name)
|
|
94
|
+
return kept
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _from_csv(text: str, column: str | None, source: str) -> list[str]:
|
|
98
|
+
return _column(((cells, False) for cells in csv.reader(io.StringIO(text))), column, source)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _from_workbook(path: Path, column: str | None, sheet: str | None) -> list[str]:
|
|
102
|
+
with sheets.open_workbook(path) as book:
|
|
103
|
+
chosen = book.sheet(sheet) if sheet is not None else _pick_sheet(book, column)
|
|
104
|
+
return _column(book.rows(chosen), column, f"sheet {chosen.name!r} of {path}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pick_sheet(book: sheets.Workbook, column: str | None) -> sheets.Sheet:
|
|
108
|
+
"""The one visible sheet with ``column`` in its header, or the first visible sheet."""
|
|
109
|
+
visible = [sheet for sheet in book.sheets if not sheet.hidden]
|
|
110
|
+
if not visible:
|
|
111
|
+
raise InputError(
|
|
112
|
+
f"{book.path} has only hidden sheets",
|
|
113
|
+
hint=f"name one with --sheet ({', '.join(s.name for s in book.sheets)})",
|
|
114
|
+
)
|
|
115
|
+
if column is None:
|
|
116
|
+
return visible[0]
|
|
117
|
+
having = [sheet for sheet in visible if _has_column(book.rows(sheet), column)]
|
|
118
|
+
if len(having) == 1:
|
|
119
|
+
return having[0]
|
|
120
|
+
names = ", ".join(sheet.name for sheet in having or visible)
|
|
121
|
+
if having:
|
|
122
|
+
raise InputError(
|
|
123
|
+
f"several sheets of {book.path} have a column {column!r}",
|
|
124
|
+
hint=f"pick one with --sheet ({names})",
|
|
125
|
+
)
|
|
126
|
+
raise InputError(
|
|
127
|
+
f"no sheet of {book.path} has a column {column!r}",
|
|
128
|
+
hint=f"check the header, or pick a sheet with --sheet ({names})",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _has_column(rows: Iterable[Row], column: str) -> bool:
|
|
133
|
+
try:
|
|
134
|
+
_header(iter(rows), column, "")
|
|
135
|
+
except InputError:
|
|
136
|
+
return False
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _column(rows: Iterable[Row], column: str | None, source: str) -> list[str]:
|
|
141
|
+
"""The non-blank values under the header ``column``, or under the only header."""
|
|
142
|
+
remaining = iter(rows)
|
|
143
|
+
index = _header(remaining, column, source)
|
|
144
|
+
values: list[str] = []
|
|
145
|
+
hidden = 0
|
|
146
|
+
for cells, row_hidden in remaining:
|
|
147
|
+
# Cells are kept whole, so a column may hold names with spaces in them.
|
|
148
|
+
value = cells[index].strip() if index < len(cells) else ""
|
|
149
|
+
if value:
|
|
150
|
+
values.append(value)
|
|
151
|
+
hidden += row_hidden
|
|
152
|
+
if hidden:
|
|
153
|
+
log.warning(
|
|
154
|
+
"%d of the names in %s are in rows hidden or filtered out in Excel; they are included",
|
|
155
|
+
hidden,
|
|
156
|
+
source,
|
|
157
|
+
)
|
|
158
|
+
return values
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _header(rows: Iterable[Row], column: str | None, source: str) -> int:
|
|
162
|
+
"""Find the header row, consuming rows up to it, and return the column's index.
|
|
163
|
+
|
|
164
|
+
Without ``column``, the header is the first non-blank row, and it must name one
|
|
165
|
+
column only. With it, the header is the first of the first few non-blank rows to
|
|
166
|
+
have that column, so title rows above the header do no harm.
|
|
167
|
+
"""
|
|
168
|
+
wanted = column.strip().casefold() if column else None
|
|
169
|
+
first: list[str] | None = None
|
|
170
|
+
for row, _ in islice(_non_blank(rows), HEADER_SEARCH_ROWS):
|
|
171
|
+
cells = [cell.strip() for cell in row]
|
|
172
|
+
first = first or cells
|
|
173
|
+
if wanted is None:
|
|
174
|
+
named = [index for index, cell in enumerate(cells) if cell]
|
|
175
|
+
if len(named) == 1:
|
|
176
|
+
return named[0]
|
|
177
|
+
raise InputError(
|
|
178
|
+
f"{source} has several columns",
|
|
179
|
+
hint=f"pick one with --column ({', '.join(cell for cell in cells if cell)})",
|
|
180
|
+
)
|
|
181
|
+
for index, cell in enumerate(cells):
|
|
182
|
+
if cell.casefold() == wanted:
|
|
183
|
+
return index
|
|
184
|
+
if first is None:
|
|
185
|
+
raise InputError(f"{source} has no header row")
|
|
186
|
+
raise InputError(
|
|
187
|
+
f"{source} has no column {column!r}",
|
|
188
|
+
hint=f"columns: {', '.join(cell for cell in first if cell)}",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _non_blank(rows: Iterable[Row]) -> Iterable[Row]:
|
|
193
|
+
return (row for row in rows if any(cell.strip() for cell in row[0]))
|